diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/admin/app/(auth)/login/page.tsx b/admin/app/(auth)/login/page.tsx new file mode 100644 index 0000000..95c6ed2 --- /dev/null +++ b/admin/app/(auth)/login/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import api from '@/app/lib/api'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPass, setShowPass] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + const login = await api.post('/admin/auth/login', { email, password }); + if (login.data.success) { + localStorage.setItem('admin_access_token', login.data.token); + router.push('/home'); + } + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setError(error?.response?.data?.message || 'Invalid email or password'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Card */} +
+ {/* Logo */} +
+
+ . +
+ + MySocial Code + +
+ + {/* Heading */} +

+ Welcome back +

+

+ Sign in to your admin account +

+ + {/* Error */} + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* Form */} +
+ {/* Email */} +
+ + setEmail(e.target.value)} + className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-[13px] text-gray-800 placeholder-gray-400 outline-none focus:border-cyan-400 focus:bg-white transition-colors" + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + className="w-full bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-[13px] text-gray-800 placeholder-gray-400 outline-none focus:border-cyan-400 focus:bg-white transition-colors pr-12" + /> + +
+
+ + {/* Submit */} + +
+ + {/* Footer */} +

+ My Social Code Admin Panel Β· All rights reserved +

+
+
+ ); +} diff --git a/admin/app/(dashboard)/events/page.tsx b/admin/app/(dashboard)/events/page.tsx new file mode 100644 index 0000000..b7c5cf1 --- /dev/null +++ b/admin/app/(dashboard)/events/page.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState } from 'react'; + +type EventStatus = 'pending' | 'approved' | 'rejected' | 'live' | 'ended'; + +interface Event { + id: number; + name: string; + organizer: string; + location: string; + date: string; + tickets: number; + price: string; + status: EventStatus; + category: string; +} + +const MOCK: Event[] = [ + { + id: 1, + name: 'Neon Rave Night', + organizer: 'Aryan Mehta', + location: 'Mumbai', + date: 'Mar 15, 2026', + tickets: 500, + price: '$20', + status: 'live', + category: 'Music', + }, + { + id: 2, + name: 'Tech Summit 2026', + organizer: 'Priya Nair', + location: 'Bangalore', + date: 'Apr 02, 2026', + tickets: 1200, + price: '$50', + status: 'approved', + category: 'Tech', + }, + { + id: 3, + name: 'Jazz Under Stars', + organizer: 'Lena Schmidt', + location: 'Delhi', + date: 'Mar 22, 2026', + tickets: 200, + price: '$15', + status: 'live', + category: 'Music', + }, + { + id: 4, + name: 'Comedy Chaos', + organizer: 'Omar Faruk', + location: 'Pune', + date: 'Feb 28, 2026', + tickets: 150, + price: '$10', + status: 'ended', + category: 'Comedy', + }, + { + id: 5, + name: 'Startup Pitch Night', + organizer: 'Kai Tanaka', + location: 'Hyderabad', + date: 'Mar 10, 2026', + tickets: 80, + price: '$25', + status: 'pending', + category: 'Business', + }, + { + id: 6, + name: 'Yoga Sunrise Retreat', + organizer: 'Meera Rao', + location: 'Goa', + date: 'Mar 30, 2026', + tickets: 60, + price: '$35', + status: 'pending', + category: 'Wellness', + }, + { + id: 7, + name: 'EDM Beach Blast', + organizer: 'Dev Sharma', + location: 'Chennai', + date: 'Apr 10, 2026', + tickets: 800, + price: '$30', + status: 'pending', + category: 'Music', + }, + { + id: 8, + name: 'Art & Wine Evening', + organizer: 'Sophia Li', + location: 'Mumbai', + date: 'Mar 18, 2026', + tickets: 120, + price: '$45', + status: 'approved', + category: 'Art', + }, + { + id: 9, + name: 'Blockchain Conference', + organizer: 'Rahul Gupta', + location: 'Delhi', + date: 'May 01, 2026', + tickets: 400, + price: '$60', + status: 'rejected', + category: 'Tech', + }, + { + id: 10, + name: 'Food Carnival 2026', + organizer: 'Amara Patel', + location: 'Kolkata', + date: 'Apr 20, 2026', + tickets: 2000, + price: 'Free', + status: 'pending', + category: 'Food', + }, +]; + +const FILTERS = [ + 'all', + 'pending', + 'approved', + 'live', + 'rejected', + 'ended', +] as const; + +const statusCls: Record = { + live: 'bg-green-50 text-green-700 border-green-200', + approved: 'bg-cyan-50 text-cyan-700 border-cyan-200', + pending: 'bg-yellow-50 text-yellow-700 border-yellow-200', + rejected: 'bg-red-50 text-red-600 border-red-200', + ended: 'bg-gray-100 text-gray-500 border-gray-200', +}; + +export default function EventsPage() { + const [events, setEvents] = useState(MOCK); + const [filter, setFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(null); + + const approve = (id: number) => + setEvents((ev) => + ev.map((e) => (e.id === id ? { ...e, status: 'approved' } : e)), + ); + const reject = (id: number) => + setEvents((ev) => + ev.map((e) => (e.id === id ? { ...e, status: 'rejected' } : e)), + ); + + const filtered = events.filter((e) => { + const mf = filter === 'all' || e.status === filter; + const ms = + e.name.toLowerCase().includes(search.toLowerCase()) || + e.organizer.toLowerCase().includes(search.toLowerCase()); + return mf && ms; + }); + + const counts = { + pending: events.filter((e) => e.status === 'pending').length, + approved: events.filter((e) => e.status === 'approved').length, + live: events.filter((e) => e.status === 'live').length, + }; + + return ( + <> + {/* Topbar */} +
+

+ Events +

+
+ πŸ”” +
+
+ +
+ {/* Summary cards */} +
+ {[ + { + label: 'Pending Review', + value: counts.pending, + color: 'text-yellow-600', + icon: '⏳', + f: 'pending', + }, + { + label: 'Approved', + value: counts.approved, + color: 'text-cyan-600', + icon: 'βœ…', + f: 'approved', + }, + { + label: 'Live Now', + value: counts.live, + color: 'text-green-600', + icon: 'πŸ”΄', + f: 'live', + }, + ].map((s) => ( +
setFilter(s.f)} + className="bg-white border border-gray-200 rounded-2xl p-5 cursor-pointer hover:-translate-y-0.5 hover:shadow-md hover:border-cyan-300 transition-all duration-200 shadow-sm" + > +
+ {s.icon} +
+

+ {s.value} +

+

{s.label}

+
+ ))} +
+ + {/* Table panel */} +
+ {/* Filter bar */} +
+
+ {FILTERS.map((f) => ( + + ))} +
+ setSearch(e.target.value)} + /> +
+ + {/* Table */} +
+ {filtered.length === 0 ? ( +
+ No events found. +
+ ) : ( + + + + {[ + '#', + 'Event', + 'Organizer', + 'Location', + 'Date', + 'Tickets', + 'Price', + 'Status', + 'Actions', + ].map((h) => ( + + ))} + + + + {filtered.map((ev) => ( + setSelected(ev)} + className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors cursor-pointer" + > + + + + + + + + + + + ))} + +
+ {h} +
{ev.id} +

{ev.name}

+

+ {ev.category} +

+
+ {ev.organizer} + + πŸ“ {ev.location} + + {ev.date} + + {ev.tickets} + + {ev.price} + e.stopPropagation()} + > + + {ev.status} + + e.stopPropagation()} + > +
+ {(ev.status === 'pending' || + ev.status === 'rejected') && ( + + )} + {(ev.status === 'pending' || + ev.status === 'approved') && ( + + )} + {(ev.status === 'live' || ev.status === 'ended') && ( + β€” + )} +
+
+ )} +
+
+
+ + {/* Modal */} + {selected && ( +
setSelected(null)} + > +
e.stopPropagation()} + > +

+ {selected.name} +

+ {[ + ['Organizer', selected.organizer], + ['Location', selected.location], + ['Date', selected.date], + ['Category', selected.category], + ['Tickets', selected.tickets], + ['Price', selected.price], + ['Status', selected.status], + ].map(([k, v]) => ( +
+ {k} + {String(v)} +
+ ))} +
+ {(selected.status === 'pending' || + selected.status === 'rejected') && ( + + )} + {(selected.status === 'pending' || + selected.status === 'approved') && ( + + )} + +
+
+
+ )} + + ); +} diff --git a/admin/app/(dashboard)/home/page.tsx b/admin/app/(dashboard)/home/page.tsx new file mode 100644 index 0000000..0bb0dcf --- /dev/null +++ b/admin/app/(dashboard)/home/page.tsx @@ -0,0 +1,233 @@ +'use client'; + +const STATS = [ + { + label: 'Total Users', + value: '24,521', + change: '+12.4%', + up: true, + icon: 'πŸ‘€', + }, + { label: 'Live Events', value: '138', change: '+3.1%', up: true, icon: '🎯' }, + { + label: 'Tickets Sold', + value: '9,872', + change: '-1.2%', + up: false, + icon: '🎟️', + }, + { label: 'Revenue', value: '$48,320', change: '+8.7%', up: true, icon: 'πŸ’°' }, +]; + +const ACTIVITY = [ + { + color: '#16a34a', + text: 'New user Aryan Mehta signed up via Google', + time: '2 min ago', + }, + { + color: '#0891b2', + text: "Event 'Neon Rave Night' went live β€” 340 tickets", + time: '8 min ago', + }, + { + color: '#dc2626', + text: 'User Kai Tanaka was banned by admin', + time: '15 min ago', + }, + { + color: '#d97706', + text: "Boost activated for 'Jazz Under Stars'", + time: '22 min ago', + }, + { + color: '#16a34a', + text: 'Revenue milestone: $48,000 crossed this month', + time: '1 hr ago', + }, + { + color: '#7c3aed', + text: "New event 'Tech Summit 2026' submitted for review", + time: '2 hr ago', + }, +]; + +const TOP_EVENTS = [ + { name: 'Neon Rave Night', tickets: 340, revenue: '$6,800', status: 'live' }, + { + name: 'Tech Summit 2026', + tickets: 890, + revenue: '$44,500', + status: 'upcoming', + }, + { name: 'Jazz Under Stars', tickets: 120, revenue: '$2,400', status: 'live' }, + { name: 'Comedy Chaos', tickets: 55, revenue: '$825', status: 'ended' }, +]; + +const statusCls: Record = { + live: 'bg-green-50 text-green-700 border-green-200', + upcoming: 'bg-cyan-50 text-cyan-700 border-cyan-200', + ended: 'bg-gray-100 text-gray-500 border-gray-200', +}; + +export default function HomePage() { + return ( + <> + {/* Topbar */} +
+
+

+ Dashboard +

+

Thu, Feb 19 2026

+
+
+ πŸ”” +
+
+ +
+ {/* Welcome banner */} +
+
+

+ Good morning, Admin πŸ‘‹ +

+

+ Here's what's happening with your platform today. +

+
+
+ + System Operational +
+
+ + {/* Stats */} +
+ {STATS.map((s) => ( +
+
+
+ {s.icon} +
+ + {s.up ? 'β–²' : 'β–Ό'} {s.change} + +
+

+ {s.value} +

+

{s.label}

+
+
+
+
+ ))} +
+ + {/* Two column */} +
+ {/* Top Events */} +
+
+ + Top Events This Month + + + VIEW ALL β†’ + +
+
+ + + + {['Event', 'Tickets', 'Revenue', 'Status'].map((h) => ( + + ))} + + + + {TOP_EVENTS.map((e) => ( + + + + + + + ))} + +
+ {h} +
+ {e.name} + + {e.tickets} + + {e.revenue} + + + {e.status} + +
+
+
+ + {/* Activity feed */} +
+
+ + Live Activity + + + + LIVE + +
+
+ {ACTIVITY.map((a, i) => ( +
+
+
+

+ {a.text} +

+

{a.time}

+
+
+ ))} +
+
+
+
+ + ); +} diff --git a/admin/app/(dashboard)/layout.tsx b/admin/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..42547f3 --- /dev/null +++ b/admin/app/(dashboard)/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; +import Sidebar from '@/components/admin/sideBar'; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/admin/app/(dashboard)/users/page.tsx b/admin/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..ea7006d --- /dev/null +++ b/admin/app/(dashboard)/users/page.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import api from '@/app/lib/api'; + +type UserStatus = 'active' | 'inactive' | 'banned'; + +interface User { + id: string; + name: string; + email: string; + joinedAt: string; + eventsCount: number; + ticketsCount: number; + status: UserStatus; +} + +const STATUS_FILTERS = ['all', 'active', 'inactive', 'banned'] as const; + +const statusCls: Record = { + active: 'bg-green-50 text-green-800 border-green-300', + inactive: 'bg-gray-100 text-gray-800 border-gray-300', + banned: 'bg-red-50 text-red-700 border-red-300', +}; + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [counts, setCounts] = useState({ + all: 0, + active: 0, + inactive: 0, + banned: 0, + }); + const [filter, setFilter] = useState<'all' | UserStatus>('all'); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + + const fetchUsers = async () => { + try { + setLoading(true); + const res = await api.get('/admin/users', { + params: { + status: filter !== 'all' ? filter : undefined, + search: search || undefined, + }, + }); + setUsers(res.data.data); + setCounts(res.data.counts); + } catch (err) { + console.error('Failed to fetch users', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, [filter, search]); + + const toggleStatus = async (userId: string) => { + try { + await api.put(`/admin/users/${userId}/status`); + fetchUsers(); + } catch (err) { + console.error('Failed to toggle status', err); + } + }; + + return ( + <> +
+
+

+ Users +

+ + {counts.all} total + +
+
+ +
+
+ {[ + { label: 'Total Users', value: counts.all }, + { label: 'Active', value: counts.active }, + { label: 'Inactive', value: counts.inactive }, + { label: 'Banned', value: counts.banned }, + ].map((s) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ +
+
+
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+ + setSearch(e.target.value)} + /> +
+ + {loading ? ( +
Loading...
+ ) : ( + + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
IDNameEmailEventsTicketsStatusAction
{u.id}{u.name}{u.email}{u.eventsCount}{u.ticketsCount} + + {u.status} + + + +
+ No users found +
+ )} +
+
+ + ); +} diff --git a/admin/app/favicon.ico b/admin/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/admin/app/favicon.ico differ diff --git a/admin/app/globals.css b/admin/app/globals.css new file mode 100644 index 0000000..37d72f8 --- /dev/null +++ b/admin/app/globals.css @@ -0,0 +1,26 @@ +@import 'tailwindcss'; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx new file mode 100644 index 0000000..8da647e --- /dev/null +++ b/admin/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/admin/app/lib/api.ts b/admin/app/lib/api.ts new file mode 100644 index 0000000..9e1cd8b --- /dev/null +++ b/admin/app/lib/api.ts @@ -0,0 +1,76 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; + +const api = axios.create({ + baseURL: 'http://10.10.2.183:4000', + withCredentials: true, +}); + +api.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('admin_access_token'); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + + return config; + }, + (error) => Promise.reject(error), +); + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + interface RetryAxiosRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; + } + + const originalRequest = error.config as RetryAxiosRequestConfig; + + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest._retry + ) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('admin_refresh_token'); + + if (!refreshToken) { + logout(); + return Promise.reject(error); + } + + const refreshResponse = await axios.post( + 'http://10.10.2.183:4000/admin/auth/refresh-token', + { refreshToken }, + ); + + const newAccessToken = refreshResponse.data.accessToken; + + localStorage.setItem('admin_access_token', newAccessToken); + + // Update header for retried request + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return api(originalRequest); + } catch (refreshError) { + logout(); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +function logout() { + localStorage.removeItem('admin_access_token'); + localStorage.removeItem('admin_refresh_token'); + window.location.href = '/login'; +} + +export default api; diff --git a/admin/app/page.tsx b/admin/app/page.tsx new file mode 100644 index 0000000..92743ec --- /dev/null +++ b/admin/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +export default function RootPage() { + redirect('/login'); +} +//comment diff --git a/admin/components/admin/sideBar.tsx b/admin/components/admin/sideBar.tsx new file mode 100644 index 0000000..01e640f --- /dev/null +++ b/admin/components/admin/sideBar.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +const NAV = [ + { href: '/home', icon: '⬛', label: 'Home' }, + { href: '/events', icon: '🎯', label: 'Events' }, + { href: '/users', icon: 'πŸ‘₯', label: 'Users' }, +]; + +export default function Sidebar() { + const path = usePathname(); + + return ( + + ); +} diff --git a/admin/eslint.config.mjs b/admin/eslint.config.mjs new file mode 100644 index 0000000..626ca82 --- /dev/null +++ b/admin/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + ]), +]); + +export default eslintConfig; diff --git a/admin/next.config.ts b/admin/next.config.ts new file mode 100644 index 0000000..5e891cf --- /dev/null +++ b/admin/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..f0f72a3 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,27 @@ +{ + "name": "admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "axios": "^1.13.5", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml new file mode 100644 index 0000000..91f1cfc --- /dev/null +++ b/admin/pnpm-lock.yaml @@ -0,0 +1,5397 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + axios: + specifier: ^1.13.5 + version: 1.13.5 + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.0 + '@types/node': + specifier: ^20 + version: 20.19.33 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.39.2(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.0 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + '@alloc/quick-lru@5.2.0': + resolution: + { + integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, + } + engines: { node: '>=10' } + + '@babel/code-frame@7.29.0': + resolution: + { + integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, + } + engines: { node: '>=6.9.0' } + + '@babel/compat-data@7.29.0': + resolution: + { + integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==, + } + engines: { node: '>=6.9.0' } + + '@babel/core@7.29.0': + resolution: + { + integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==, + } + engines: { node: '>=6.9.0' } + + '@babel/generator@7.29.1': + resolution: + { + integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-compilation-targets@7.28.6': + resolution: + { + integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-globals@7.28.0': + resolution: + { + integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-module-imports@7.28.6': + resolution: + { + integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-module-transforms@7.28.6': + resolution: + { + integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: + { + integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-identifier@7.28.5': + resolution: + { + integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-option@7.27.1': + resolution: + { + integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, + } + engines: { node: '>=6.9.0' } + + '@babel/helpers@7.28.6': + resolution: + { + integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==, + } + engines: { node: '>=6.9.0' } + + '@babel/parser@7.29.0': + resolution: + { + integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + '@babel/template@7.28.6': + resolution: + { + integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, + } + engines: { node: '>=6.9.0' } + + '@babel/traverse@7.29.0': + resolution: + { + integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, + } + engines: { node: '>=6.9.0' } + + '@babel/types@7.29.0': + resolution: + { + integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, + } + engines: { node: '>=6.9.0' } + + '@emnapi/core@1.8.1': + resolution: + { + integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==, + } + + '@emnapi/runtime@1.8.1': + resolution: + { + integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==, + } + + '@emnapi/wasi-threads@1.1.0': + resolution: + { + integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==, + } + + '@eslint-community/eslint-utils@4.9.1': + resolution: + { + integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint/config-array@0.21.1': + resolution: + { + integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/config-helpers@0.4.2': + resolution: + { + integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/core@0.17.0': + resolution: + { + integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/eslintrc@3.3.3': + resolution: + { + integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/js@9.39.2': + resolution: + { + integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/object-schema@2.1.7': + resolution: + { + integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/plugin-kit@0.4.1': + resolution: + { + integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@humanfs/core@0.19.1': + resolution: + { + integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, + } + engines: { node: '>=18.18.0' } + + '@humanfs/node@0.16.7': + resolution: + { + integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==, + } + engines: { node: '>=18.18.0' } + + '@humanwhocodes/module-importer@1.0.1': + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: '>=12.22' } + + '@humanwhocodes/retry@0.4.3': + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: '>=18.18' } + + '@img/colour@1.0.0': + resolution: + { + integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==, + } + engines: { node: '>=18' } + + '@img/sharp-darwin-arm64@0.34.5': + resolution: + { + integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: + { + integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: + { + integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, + } + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: + { + integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, + } + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: + { + integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, + } + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: + { + integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, + } + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: + { + integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, + } + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: + { + integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, + } + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: + { + integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, + } + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: + { + integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, + } + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: + { + integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, + } + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: + { + integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, + } + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: + { + integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: + { + integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: + { + integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: + { + integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: + { + integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: + { + integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: + { + integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: + { + integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: + { + integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: + { + integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: + { + integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: + { + integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: + { + integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, + } + + '@jridgewell/remapping@2.3.5': + resolution: + { + integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, + } + + '@jridgewell/resolve-uri@3.1.2': + resolution: + { + integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, + } + engines: { node: '>=6.0.0' } + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: + { + integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, + } + + '@jridgewell/trace-mapping@0.3.31': + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, + } + + '@napi-rs/wasm-runtime@0.2.12': + resolution: + { + integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==, + } + + '@next/env@16.1.6': + resolution: + { + integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==, + } + + '@next/eslint-plugin-next@16.1.6': + resolution: + { + integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==, + } + + '@next/swc-darwin-arm64@16.1.6': + resolution: + { + integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: + { + integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: + { + integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: + { + integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: + { + integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: + { + integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: + { + integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: + { + integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: + { + integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.stat@2.0.5': + resolution: + { + integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.walk@1.2.8': + resolution: + { + integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, + } + engines: { node: '>= 8' } + + '@nolyfill/is-core-module@1.0.39': + resolution: + { + integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==, + } + engines: { node: '>=12.4.0' } + + '@rtsao/scc@1.1.0': + resolution: + { + integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==, + } + + '@swc/helpers@0.5.15': + resolution: + { + integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==, + } + + '@tailwindcss/node@4.2.0': + resolution: + { + integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==, + } + + '@tailwindcss/oxide-android-arm64@4.2.0': + resolution: + { + integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.0': + resolution: + { + integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.0': + resolution: + { + integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.0': + resolution: + { + integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + resolution: + { + integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==, + } + engines: { node: '>= 20' } + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + resolution: + { + integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + resolution: + { + integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + resolution: + { + integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + resolution: + { + integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + resolution: + { + integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==, + } + engines: { node: '>=14.0.0' } + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + resolution: + { + integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + resolution: + { + integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.0': + resolution: + { + integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==, + } + engines: { node: '>= 20' } + + '@tailwindcss/postcss@4.2.0': + resolution: + { + integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==, + } + + '@tybys/wasm-util@0.10.1': + resolution: + { + integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, + } + + '@types/estree@1.0.8': + resolution: + { + integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, + } + + '@types/json-schema@7.0.15': + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } + + '@types/json5@0.0.29': + resolution: + { + integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==, + } + + '@types/node@20.19.33': + resolution: + { + integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==, + } + + '@types/react-dom@19.2.3': + resolution: + { + integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==, + } + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: + { + integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==, + } + + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: + { + integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.0': + resolution: + { + integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.0': + resolution: + { + integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.0': + resolution: + { + integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/tsconfig-utils@8.56.0': + resolution: + { + integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.0': + resolution: + { + integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.0': + resolution: + { + integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/typescript-estree@8.56.0': + resolution: + { + integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.0': + resolution: + { + integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.0': + resolution: + { + integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: + { + integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==, + } + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: + { + integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==, + } + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: + { + integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==, + } + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: + { + integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==, + } + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: + { + integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==, + } + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: + { + integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==, + } + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: + { + integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==, + } + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: + { + integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==, + } + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: + { + integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==, + } + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: + { + integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==, + } + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: + { + integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==, + } + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: + { + integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==, + } + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: + { + integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==, + } + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: + { + integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==, + } + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: + { + integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==, + } + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: + { + integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==, + } + engines: { node: '>=14.0.0' } + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: + { + integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==, + } + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: + { + integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==, + } + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: + { + integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==, + } + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: + { + integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==, + } + engines: { node: '>=0.4.0' } + hasBin: true + + ajv@6.12.6: + resolution: + { + integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, + } + + ansi-styles@4.3.0: + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, + } + engines: { node: '>=8' } + + argparse@2.0.1: + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } + + aria-query@5.3.2: + resolution: + { + integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, + } + engines: { node: '>= 0.4' } + + array-buffer-byte-length@1.0.2: + resolution: + { + integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==, + } + engines: { node: '>= 0.4' } + + array-includes@3.1.9: + resolution: + { + integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==, + } + engines: { node: '>= 0.4' } + + array.prototype.findlast@1.2.5: + resolution: + { + integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==, + } + engines: { node: '>= 0.4' } + + array.prototype.findlastindex@1.2.6: + resolution: + { + integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==, + } + engines: { node: '>= 0.4' } + + array.prototype.flat@1.3.3: + resolution: + { + integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==, + } + engines: { node: '>= 0.4' } + + array.prototype.flatmap@1.3.3: + resolution: + { + integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==, + } + engines: { node: '>= 0.4' } + + array.prototype.tosorted@1.1.4: + resolution: + { + integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==, + } + engines: { node: '>= 0.4' } + + arraybuffer.prototype.slice@1.0.4: + resolution: + { + integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==, + } + engines: { node: '>= 0.4' } + + ast-types-flow@0.0.8: + resolution: + { + integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==, + } + + async-function@1.0.0: + resolution: + { + integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==, + } + engines: { node: '>= 0.4' } + + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + + available-typed-arrays@1.0.7: + resolution: + { + integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==, + } + engines: { node: '>= 0.4' } + + axe-core@4.11.1: + resolution: + { + integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==, + } + engines: { node: '>=4' } + + axios@1.13.5: + resolution: + { + integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==, + } + + axobject-query@4.1.0: + resolution: + { + integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, + } + engines: { node: '>= 0.4' } + + balanced-match@1.0.2: + resolution: + { + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, + } + + baseline-browser-mapping@2.9.19: + resolution: + { + integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==, + } + hasBin: true + + brace-expansion@1.1.12: + resolution: + { + integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==, + } + + brace-expansion@2.0.2: + resolution: + { + integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==, + } + + braces@3.0.3: + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, + } + engines: { node: '>=8' } + + browserslist@4.28.1: + resolution: + { + integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==, + } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: + { + integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, + } + engines: { node: '>= 0.4' } + + call-bind@1.0.8: + resolution: + { + integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==, + } + engines: { node: '>= 0.4' } + + call-bound@1.0.4: + resolution: + { + integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, + } + engines: { node: '>= 0.4' } + + callsites@3.1.0: + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: '>=6' } + + caniuse-lite@1.0.30001770: + resolution: + { + integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==, + } + + chalk@4.1.2: + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, + } + engines: { node: '>=10' } + + client-only@0.0.1: + resolution: + { + integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, + } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: '>=7.0.0' } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } + + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: '>= 0.8' } + + concat-map@0.0.1: + resolution: + { + integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, + } + + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: '>= 8' } + + csstype@3.2.3: + resolution: + { + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, + } + + damerau-levenshtein@1.0.8: + resolution: + { + integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==, + } + + data-view-buffer@1.0.2: + resolution: + { + integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==, + } + engines: { node: '>= 0.4' } + + data-view-byte-length@1.0.2: + resolution: + { + integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==, + } + engines: { node: '>= 0.4' } + + data-view-byte-offset@1.0.1: + resolution: + { + integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==, + } + engines: { node: '>= 0.4' } + + debug@3.2.7: + resolution: + { + integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==, + } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } + + define-data-property@1.1.4: + resolution: + { + integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==, + } + engines: { node: '>= 0.4' } + + define-properties@1.2.1: + resolution: + { + integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==, + } + engines: { node: '>= 0.4' } + + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, + } + engines: { node: '>=0.4.0' } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: '>=8' } + + doctrine@2.1.0: + resolution: + { + integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==, + } + engines: { node: '>=0.10.0' } + + dunder-proto@1.0.1: + resolution: + { + integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, + } + engines: { node: '>= 0.4' } + + electron-to-chromium@1.5.286: + resolution: + { + integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==, + } + + emoji-regex@9.2.2: + resolution: + { + integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, + } + + enhanced-resolve@5.19.0: + resolution: + { + integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==, + } + engines: { node: '>=10.13.0' } + + es-abstract@1.24.1: + resolution: + { + integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==, + } + engines: { node: '>= 0.4' } + + es-define-property@1.0.1: + resolution: + { + integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, + } + engines: { node: '>= 0.4' } + + es-errors@1.3.0: + resolution: + { + integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, + } + engines: { node: '>= 0.4' } + + es-iterator-helpers@1.2.2: + resolution: + { + integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==, + } + engines: { node: '>= 0.4' } + + es-object-atoms@1.1.1: + resolution: + { + integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, + } + engines: { node: '>= 0.4' } + + es-set-tostringtag@2.1.0: + resolution: + { + integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==, + } + engines: { node: '>= 0.4' } + + es-shim-unscopables@1.1.0: + resolution: + { + integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==, + } + engines: { node: '>= 0.4' } + + es-to-primitive@1.3.0: + resolution: + { + integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==, + } + engines: { node: '>= 0.4' } + + escalade@3.2.0: + resolution: + { + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, + } + engines: { node: '>=6' } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: '>=10' } + + eslint-config-next@16.1.6: + resolution: + { + integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==, + } + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: + { + integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==, + } + + eslint-import-resolver-typescript@3.10.1: + resolution: + { + integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: + { + integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==, + } + engines: { node: '>=4' } + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: + { + integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==, + } + engines: { node: '>=4' } + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: + { + integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==, + } + engines: { node: '>=4.0' } + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: + { + integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==, + } + engines: { node: '>=18' } + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: + { + integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==, + } + engines: { node: '>=4' } + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: + { + integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@4.2.1: + resolution: + { + integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@5.0.0: + resolution: + { + integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@9.39.2: + resolution: + { + integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: + { + integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==, + } + engines: { node: '>=0.10' } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: '>=4.0' } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: '>=4.0' } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: '>=0.10.0' } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-glob@3.3.1: + resolution: + { + integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==, + } + engines: { node: '>=8.6.0' } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } + + fastq@1.20.1: + resolution: + { + integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: '>=12.0.0' } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: '>=16.0.0' } + + fill-range@7.1.1: + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, + } + engines: { node: '>=8' } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: '>=10' } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: '>=16' } + + flatted@3.3.3: + resolution: + { + integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, + } + + follow-redirects@1.15.11: + resolution: + { + integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==, + } + engines: { node: '>=4.0' } + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: + { + integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==, + } + engines: { node: '>= 0.4' } + + form-data@4.0.5: + resolution: + { + integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==, + } + engines: { node: '>= 6' } + + function-bind@1.1.2: + resolution: + { + integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, + } + + function.prototype.name@1.1.8: + resolution: + { + integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==, + } + engines: { node: '>= 0.4' } + + functions-have-names@1.2.3: + resolution: + { + integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==, + } + + generator-function@2.0.1: + resolution: + { + integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==, + } + engines: { node: '>= 0.4' } + + gensync@1.0.0-beta.2: + resolution: + { + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, + } + engines: { node: '>=6.9.0' } + + get-intrinsic@1.3.0: + resolution: + { + integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, + } + engines: { node: '>= 0.4' } + + get-proto@1.0.1: + resolution: + { + integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, + } + engines: { node: '>= 0.4' } + + get-symbol-description@1.1.0: + resolution: + { + integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==, + } + engines: { node: '>= 0.4' } + + get-tsconfig@4.13.6: + resolution: + { + integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==, + } + + glob-parent@5.1.2: + resolution: + { + integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, + } + engines: { node: '>= 6' } + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: '>=10.13.0' } + + globals@14.0.0: + resolution: + { + integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, + } + engines: { node: '>=18' } + + globals@16.4.0: + resolution: + { + integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==, + } + engines: { node: '>=18' } + + globalthis@1.0.4: + resolution: + { + integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==, + } + engines: { node: '>= 0.4' } + + gopd@1.2.0: + resolution: + { + integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, + } + engines: { node: '>= 0.4' } + + graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, + } + + has-bigints@1.1.0: + resolution: + { + integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==, + } + engines: { node: '>= 0.4' } + + has-flag@4.0.0: + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, + } + engines: { node: '>=8' } + + has-property-descriptors@1.0.2: + resolution: + { + integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==, + } + + has-proto@1.2.0: + resolution: + { + integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==, + } + engines: { node: '>= 0.4' } + + has-symbols@1.1.0: + resolution: + { + integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, + } + engines: { node: '>= 0.4' } + + has-tostringtag@1.0.2: + resolution: + { + integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, + } + engines: { node: '>= 0.4' } + + hasown@2.0.2: + resolution: + { + integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, + } + engines: { node: '>= 0.4' } + + hermes-estree@0.25.1: + resolution: + { + integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==, + } + + hermes-parser@0.25.1: + resolution: + { + integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==, + } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: '>= 4' } + + ignore@7.0.5: + resolution: + { + integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, + } + engines: { node: '>= 4' } + + import-fresh@3.3.1: + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, + } + engines: { node: '>=6' } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: '>=0.8.19' } + + internal-slot@1.1.0: + resolution: + { + integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==, + } + engines: { node: '>= 0.4' } + + is-array-buffer@3.0.5: + resolution: + { + integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==, + } + engines: { node: '>= 0.4' } + + is-async-function@2.1.1: + resolution: + { + integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==, + } + engines: { node: '>= 0.4' } + + is-bigint@1.1.0: + resolution: + { + integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==, + } + engines: { node: '>= 0.4' } + + is-boolean-object@1.2.2: + resolution: + { + integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==, + } + engines: { node: '>= 0.4' } + + is-bun-module@2.0.0: + resolution: + { + integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==, + } + + is-callable@1.2.7: + resolution: + { + integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==, + } + engines: { node: '>= 0.4' } + + is-core-module@2.16.1: + resolution: + { + integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==, + } + engines: { node: '>= 0.4' } + + is-data-view@1.0.2: + resolution: + { + integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==, + } + engines: { node: '>= 0.4' } + + is-date-object@1.1.0: + resolution: + { + integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==, + } + engines: { node: '>= 0.4' } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: '>=0.10.0' } + + is-finalizationregistry@1.1.1: + resolution: + { + integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==, + } + engines: { node: '>= 0.4' } + + is-generator-function@1.1.2: + resolution: + { + integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==, + } + engines: { node: '>= 0.4' } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: '>=0.10.0' } + + is-map@2.0.3: + resolution: + { + integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==, + } + engines: { node: '>= 0.4' } + + is-negative-zero@2.0.3: + resolution: + { + integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==, + } + engines: { node: '>= 0.4' } + + is-number-object@1.1.1: + resolution: + { + integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==, + } + engines: { node: '>= 0.4' } + + is-number@7.0.0: + resolution: + { + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, + } + engines: { node: '>=0.12.0' } + + is-regex@1.2.1: + resolution: + { + integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==, + } + engines: { node: '>= 0.4' } + + is-set@2.0.3: + resolution: + { + integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==, + } + engines: { node: '>= 0.4' } + + is-shared-array-buffer@1.0.4: + resolution: + { + integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==, + } + engines: { node: '>= 0.4' } + + is-string@1.1.1: + resolution: + { + integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==, + } + engines: { node: '>= 0.4' } + + is-symbol@1.1.1: + resolution: + { + integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==, + } + engines: { node: '>= 0.4' } + + is-typed-array@1.1.15: + resolution: + { + integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==, + } + engines: { node: '>= 0.4' } + + is-weakmap@2.0.2: + resolution: + { + integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==, + } + engines: { node: '>= 0.4' } + + is-weakref@1.1.1: + resolution: + { + integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==, + } + engines: { node: '>= 0.4' } + + is-weakset@2.0.4: + resolution: + { + integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==, + } + engines: { node: '>= 0.4' } + + isarray@2.0.5: + resolution: + { + integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==, + } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + iterator.prototype@1.1.5: + resolution: + { + integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==, + } + engines: { node: '>= 0.4' } + + jiti@2.6.1: + resolution: + { + integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, + } + hasBin: true + + js-tokens@4.0.0: + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + } + + js-yaml@4.1.1: + resolution: + { + integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==, + } + hasBin: true + + jsesc@3.1.0: + resolution: + { + integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, + } + engines: { node: '>=6' } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + json5@1.0.2: + resolution: + { + integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==, + } + hasBin: true + + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + } + engines: { node: '>=6' } + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: + { + integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==, + } + engines: { node: '>=4.0' } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } + + language-subtag-registry@0.3.23: + resolution: + { + integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==, + } + + language-tags@1.0.9: + resolution: + { + integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==, + } + engines: { node: '>=0.10' } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: '>= 0.8.0' } + + lightningcss-android-arm64@1.31.1: + resolution: + { + integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: + { + integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: + { + integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: + { + integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: + { + integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: + { + integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: + { + integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: + { + integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: + { + integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: + { + integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: + { + integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: + { + integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==, + } + engines: { node: '>= 12.0.0' } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: '>=10' } + + lodash.merge@4.6.2: + resolution: + { + integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, + } + + loose-envify@1.4.0: + resolution: + { + integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, + } + hasBin: true + + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, + } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + + math-intrinsics@1.1.0: + resolution: + { + integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, + } + engines: { node: '>= 0.4' } + + merge2@1.4.1: + resolution: + { + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + } + engines: { node: '>= 8' } + + micromatch@4.0.8: + resolution: + { + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + } + engines: { node: '>=8.6' } + + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: '>= 0.6' } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: '>= 0.6' } + + minimatch@3.1.2: + resolution: + { + integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, + } + + minimatch@9.0.5: + resolution: + { + integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, + } + engines: { node: '>=16 || 14 >=14.17' } + + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, + } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + nanoid@3.3.11: + resolution: + { + integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + napi-postinstall@0.3.4: + resolution: + { + integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==, + } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } + hasBin: true + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + next@16.1.6: + resolution: + { + integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==, + } + engines: { node: '>=20.9.0' } + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: + { + integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==, + } + engines: { node: '>= 0.4' } + + node-releases@2.0.27: + resolution: + { + integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==, + } + + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, + } + engines: { node: '>=0.10.0' } + + object-inspect@1.13.4: + resolution: + { + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, + } + engines: { node: '>= 0.4' } + + object-keys@1.1.1: + resolution: + { + integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==, + } + engines: { node: '>= 0.4' } + + object.assign@4.1.7: + resolution: + { + integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==, + } + engines: { node: '>= 0.4' } + + object.entries@1.1.9: + resolution: + { + integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==, + } + engines: { node: '>= 0.4' } + + object.fromentries@2.0.8: + resolution: + { + integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==, + } + engines: { node: '>= 0.4' } + + object.groupby@1.0.3: + resolution: + { + integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==, + } + engines: { node: '>= 0.4' } + + object.values@1.2.1: + resolution: + { + integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==, + } + engines: { node: '>= 0.4' } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: '>= 0.8.0' } + + own-keys@1.0.1: + resolution: + { + integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==, + } + engines: { node: '>= 0.4' } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: '>=10' } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: '>=10' } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: '>=6' } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: '>=8' } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: '>=8' } + + path-parse@1.0.7: + resolution: + { + integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@2.3.1: + resolution: + { + integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, + } + engines: { node: '>=8.6' } + + picomatch@4.0.3: + resolution: + { + integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, + } + engines: { node: '>=12' } + + possible-typed-array-names@1.1.0: + resolution: + { + integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==, + } + engines: { node: '>= 0.4' } + + postcss@8.4.31: + resolution: + { + integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==, + } + engines: { node: ^10 || ^12 || >=14 } + + postcss@8.5.6: + resolution: + { + integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, + } + engines: { node: ^10 || ^12 || >=14 } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: '>= 0.8.0' } + + prop-types@15.8.1: + resolution: + { + integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, + } + + proxy-from-env@1.1.0: + resolution: + { + integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: '>=6' } + + queue-microtask@1.2.3: + resolution: + { + integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, + } + + react-dom@19.2.3: + resolution: + { + integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==, + } + peerDependencies: + react: ^19.2.3 + + react-is@16.13.1: + resolution: + { + integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, + } + + react@19.2.3: + resolution: + { + integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==, + } + engines: { node: '>=0.10.0' } + + reflect.getprototypeof@1.0.10: + resolution: + { + integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==, + } + engines: { node: '>= 0.4' } + + regexp.prototype.flags@1.5.4: + resolution: + { + integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==, + } + engines: { node: '>= 0.4' } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: '>=4' } + + resolve-pkg-maps@1.0.0: + resolution: + { + integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, + } + + resolve@1.22.11: + resolution: + { + integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==, + } + engines: { node: '>= 0.4' } + hasBin: true + + resolve@2.0.0-next.6: + resolution: + { + integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==, + } + engines: { node: '>= 0.4' } + hasBin: true + + reusify@1.1.0: + resolution: + { + integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, + } + engines: { iojs: '>=1.0.0', node: '>=0.10.0' } + + run-parallel@1.2.0: + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } + + safe-array-concat@1.1.3: + resolution: + { + integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==, + } + engines: { node: '>=0.4' } + + safe-push-apply@1.0.0: + resolution: + { + integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==, + } + engines: { node: '>= 0.4' } + + safe-regex-test@1.1.0: + resolution: + { + integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==, + } + engines: { node: '>= 0.4' } + + scheduler@0.27.0: + resolution: + { + integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, + } + + semver@6.3.1: + resolution: + { + integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, + } + hasBin: true + + semver@7.7.4: + resolution: + { + integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==, + } + engines: { node: '>=10' } + hasBin: true + + set-function-length@1.2.2: + resolution: + { + integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==, + } + engines: { node: '>= 0.4' } + + set-function-name@2.0.2: + resolution: + { + integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==, + } + engines: { node: '>= 0.4' } + + set-proto@1.0.0: + resolution: + { + integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==, + } + engines: { node: '>= 0.4' } + + sharp@0.34.5: + resolution: + { + integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: '>=8' } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: '>=8' } + + side-channel-list@1.0.0: + resolution: + { + integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, + } + engines: { node: '>= 0.4' } + + side-channel-map@1.0.1: + resolution: + { + integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, + } + engines: { node: '>= 0.4' } + + side-channel-weakmap@1.0.2: + resolution: + { + integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, + } + engines: { node: '>= 0.4' } + + side-channel@1.1.0: + resolution: + { + integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, + } + engines: { node: '>= 0.4' } + + source-map-js@1.2.1: + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: '>=0.10.0' } + + stable-hash@0.0.5: + resolution: + { + integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==, + } + + stop-iteration-iterator@1.1.0: + resolution: + { + integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==, + } + engines: { node: '>= 0.4' } + + string.prototype.includes@2.0.1: + resolution: + { + integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==, + } + engines: { node: '>= 0.4' } + + string.prototype.matchall@4.0.12: + resolution: + { + integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==, + } + engines: { node: '>= 0.4' } + + string.prototype.repeat@1.0.0: + resolution: + { + integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==, + } + + string.prototype.trim@1.2.10: + resolution: + { + integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==, + } + engines: { node: '>= 0.4' } + + string.prototype.trimend@1.0.9: + resolution: + { + integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==, + } + engines: { node: '>= 0.4' } + + string.prototype.trimstart@1.0.8: + resolution: + { + integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==, + } + engines: { node: '>= 0.4' } + + strip-bom@3.0.0: + resolution: + { + integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, + } + engines: { node: '>=4' } + + strip-json-comments@3.1.1: + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, + } + engines: { node: '>=8' } + + styled-jsx@5.1.6: + resolution: + { + integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==, + } + engines: { node: '>= 12.0.0' } + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, + } + engines: { node: '>=8' } + + supports-preserve-symlinks-flag@1.0.0: + resolution: + { + integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, + } + engines: { node: '>= 0.4' } + + tailwindcss@4.2.0: + resolution: + { + integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==, + } + + tapable@2.3.0: + resolution: + { + integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==, + } + engines: { node: '>=6' } + + tinyglobby@0.2.15: + resolution: + { + integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, + } + engines: { node: '>=12.0.0' } + + to-regex-range@5.0.1: + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, + } + engines: { node: '>=8.0' } + + ts-api-utils@2.4.0: + resolution: + { + integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==, + } + engines: { node: '>=18.12' } + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: + { + integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==, + } + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: '>= 0.8.0' } + + typed-array-buffer@1.0.3: + resolution: + { + integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==, + } + engines: { node: '>= 0.4' } + + typed-array-byte-length@1.0.3: + resolution: + { + integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==, + } + engines: { node: '>= 0.4' } + + typed-array-byte-offset@1.0.4: + resolution: + { + integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==, + } + engines: { node: '>= 0.4' } + + typed-array-length@1.0.7: + resolution: + { + integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==, + } + engines: { node: '>= 0.4' } + + typescript-eslint@8.56.0: + resolution: + { + integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, + } + engines: { node: '>=14.17' } + hasBin: true + + unbox-primitive@1.1.0: + resolution: + { + integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==, + } + engines: { node: '>= 0.4' } + + undici-types@6.21.0: + resolution: + { + integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, + } + + unrs-resolver@1.11.1: + resolution: + { + integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==, + } + + update-browserslist-db@1.2.3: + resolution: + { + integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==, + } + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } + + which-boxed-primitive@1.1.1: + resolution: + { + integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==, + } + engines: { node: '>= 0.4' } + + which-builtin-type@1.2.1: + resolution: + { + integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==, + } + engines: { node: '>= 0.4' } + + which-collection@1.0.2: + resolution: + { + integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==, + } + engines: { node: '>= 0.4' } + + which-typed-array@1.1.20: + resolution: + { + integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==, + } + engines: { node: '>= 0.4' } + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: '>= 8' } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: '>=0.10.0' } + + yallist@3.1.1: + resolution: + { + integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, + } + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: '>=10' } + + zod-validation-error@4.0.2: + resolution: + { + integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==, + } + engines: { node: '>=18.0.0' } + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: + { + integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==, + } + +snapshots: + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.1.6': {} + + '@next/eslint-plugin-next@16.1.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@rtsao/scc@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.0 + + '@tailwindcss/oxide-android-arm64@4.2.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + optional: true + + '@tailwindcss/oxide@4.2.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-arm64': 4.2.0 + '@tailwindcss/oxide-darwin-x64': 4.2.0 + '@tailwindcss/oxide-freebsd-x64': 4.2.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 + '@tailwindcss/oxide-linux-x64-musl': 4.2.0 + '@tailwindcss/oxide-wasm32-wasi': 4.2.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + + '@tailwindcss/postcss@4.2.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.0 + '@tailwindcss/oxide': 4.2.0 + postcss: 8.5.6 + tailwindcss: 4.2.0 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.33': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.0': {} + + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/visitor-keys': 8.56.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.0': + dependencies: + '@typescript-eslint/types': 8.56.0 + eslint-visitor-keys: 5.0.0 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.1: {} + + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.9.19: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001770 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001770: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.286: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.2(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.2(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.0: {} + + eslint@9.39.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + 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.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001770 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.27: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.3: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): + dependencies: + client-only: 0.0.1 + react: 19.2.3 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@4.2.0: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/admin/pnpm-workspace.yaml b/admin/pnpm-workspace.yaml new file mode 100644 index 0000000..581a9d5 --- /dev/null +++ b/admin/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/admin/postcss.config.mjs b/admin/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/admin/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/admin/public/file.svg b/admin/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/admin/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/globe.svg b/admin/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/admin/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/next.svg b/admin/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/admin/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/vercel.svg b/admin/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/admin/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/public/window.svg b/admin/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/admin/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..3a13f90 --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/backend/package.json b/backend/package.json index 1f7c1a0..dfce0fc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,9 +3,9 @@ "private": true, "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "dev:worker": "ts-node src/messaging/rabbitmq/consume/otp.worker.ts", "build": "tsc", - "start": "node dist/server.js", - "lint": "eslint" + "start": "node dist/server.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.962.0", @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "form-data": "^4.0.5", "jsonwebtoken": "^9.0.3", "mailgun.js": "^12.4.1", @@ -21,9 +22,14 @@ "pg": "^8.16.3", "pino": "^10.1.0", "pino-http": "^11.0.0", + "qrcode": "^1.5.4", + "rate-limit-redis": "^4.3.1", + "razorpay": "^2.9.6", + "redis": "^5.10.0", "reflect-metadata": "^0.2.2", "twilio": "^5.11.1", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "uuid": "^13.0.0" }, "devDependencies": { "@commitlint/cli": "^20.2.0", @@ -37,6 +43,7 @@ "@types/multer": "^2.0.0", "@types/node": "^25.0.3", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "eslint": "^9.39.2", diff --git a/backend/src/Services/email.service.ts b/backend/src/Services/email.service.ts index cd74cac..562d4d3 100644 --- a/backend/src/Services/email.service.ts +++ b/backend/src/Services/email.service.ts @@ -1,19 +1,20 @@ import Mailgun from 'mailgun.js'; import FormData from 'form-data'; import { logger } from '../utils/logger'; +import { env } from '../config/env'; const mailgun = new Mailgun(FormData); const mg = mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY!, + key: env.MAILGUN_API_KEY!, }); -export const sendOtpEmail = async (to: string, otp: string) => { +export const sendOtpEmail = async (email: string, otp: number) => { try { - await mg.messages.create(process.env.MAILGUN_DOMAIN!, { - from: process.env.MAIL_FROM_EMAIL!, - to, + await mg.messages.create(env.MAILGUN_DOMAIN!, { + from: env.MAIL_FROM_EMAIL!, + to: email, subject: 'Your SocialCode verification code', text: `Your SocialCode verification code is ${otp}. This code expires in 5 minutes.`, }); @@ -22,3 +23,46 @@ export const sendOtpEmail = async (to: string, otp: string) => { throw new Error('Failed to send OTP email'); } }; + +const resetTemplate = (url: string): string => ` +
+

Reset Your Password

+

Click the button below to reset your password:

+ + + Reset Password + + +

Or use this link:

+

${url}

+ +

This link expires in 15 minutes.

+
+`; + +export const sendLinkEmail = async (to: string, url: string): Promise => { + try { + await mg.messages.create(env.MAILGUN_DOMAIN!, { + from: env.MAIL_FROM_EMAIL!, + to, + subject: 'Reset your SocialCode password', + + text: `Reset your password using this link: ${url}`, + + html: resetTemplate(url), + }); + + console.log('Reset email sent successfully'); + } catch (error) { + console.error('Error sending reset email:', error); + throw error; + } +}; diff --git a/backend/src/Services/jwt.service.ts b/backend/src/Services/jwt.service.ts index cb7e9e6..36630c2 100644 --- a/backend/src/Services/jwt.service.ts +++ b/backend/src/Services/jwt.service.ts @@ -1,10 +1,19 @@ import jwt from 'jsonwebtoken'; +import { env } from '../config/env'; -export const signAccessToken = (payload: { userId: string }) => { - return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET!, { +// export const signAccessToken = (payload: { userId: string, role: string }) => { +// return jwt.sign(payload, env.ACCESS_TOKEN_SECRET!, { +// expiresIn: '15m', +// }); +// }; +export const signAccessToken = (payload: { + id: string; + type: 'USER' | 'ADMIN'; +}) => { + return jwt.sign(payload, env.ACCESS_TOKEN_SECRET!, { expiresIn: '15m', }); }; export const verifyAccessToken = (token: string) => { - return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!); + return jwt.verify(token, env.ACCESS_TOKEN_SECRET!); }; diff --git a/backend/src/Services/passwordReset.service.ts b/backend/src/Services/passwordReset.service.ts new file mode 100644 index 0000000..a69f3b9 --- /dev/null +++ b/backend/src/Services/passwordReset.service.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../config/env'; + +const PASSWORD_RESET_SECRET = env.PASSWORD_RESET_SECRET!; + +export const signPasswordResetToken = (userId: string) => { + return jwt.sign( + { + userId, + purpose: 'password_reset', + }, + PASSWORD_RESET_SECRET, + { + expiresIn: '15m', + }, + ); +}; + +export const verifyPasswordResetToken = (token: string) => { + return jwt.verify(token, PASSWORD_RESET_SECRET) as { + userId: string; + purpose: string; + }; +}; diff --git a/backend/src/Services/sms.service.ts b/backend/src/Services/sms.service.ts index 9fbb773..4a6e7ee 100644 --- a/backend/src/Services/sms.service.ts +++ b/backend/src/Services/sms.service.ts @@ -1,18 +1,23 @@ -import Twilio from 'twilio'; +import { env } from '../config/env'; import { logger } from '../utils/logger'; -const accountSid = process.env.TWILIO_ACCOUNT_SID!; -const authToken = process.env.TWILIO_AUTH_TOKEN!; -const fromNumber = process.env.TWILIO_FROM_NUMBER!; -const client = Twilio(accountSid, authToken); -export const sendOtpSms = async (phoneNumber: string, otp: string) => { - try { - await client.messages.create({ - body: `otp is ${otp}`, - from: fromNumber, - to: phoneNumber, - }); - } catch (err) { - logger.error({ err }, 'failed to send otp'); - throw new Error('failed to send'); +import twilio from 'twilio'; + +export const sendOtpSms = async (phone: string, otp: string) => { + const accountSid = env.TWILIO_ACCOUNT_SID; + const authToken = env.TWILIO_AUTH_TOKEN; + const fromNumber = env.TWILIO_FROM_NUMBER; + + if (!accountSid || !authToken || !fromNumber) { + throw new Error('Twilio env vars missing'); } + + const client = twilio(accountSid, authToken); + + await client.messages.create({ + body: `Your OTP is ${otp}`, + from: fromNumber, + to: phone, + }); + + logger.info('OTP SMS sent'); }; diff --git a/backend/src/Services/storage.service.ts b/backend/src/Services/storage.service.ts new file mode 100644 index 0000000..6770a18 --- /dev/null +++ b/backend/src/Services/storage.service.ts @@ -0,0 +1,21 @@ +// // for signing urls for s3 +// import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +// import { env } from '../config/env'; +// export const r2 = new S3Client({ +// region: 'auto', +// endpoint: env.R2_ENDPOINT, +// credentials: { +// accessKeyId: env.R2_ACCESS_KEY_ID!, +// secretAccessKey: env.R2_SECRET_ACCESS_KEY!, +// }, +// }); + +// export async function getSignedUrl(key: string, expiresInSeconds = 3600) { +// const command = new GetObjectCommand({ +// Bucket: env.R2_BUCKET_NAME!, +// Key: key, +// }); + +// const url = await r2.getSignedUrl(command, { expiresIn: expiresInSeconds }); +// return url; +// } diff --git a/backend/src/app.ts b/backend/src/app.ts index 8dd8d9e..0f19c43 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,16 +7,56 @@ import { errorHandler } from './middleware/errorHandler'; import Healthrouter from './modules/health/health'; import authRouter from './modules/auth/auth.routes'; import userRouter from './modules/user/user.routes'; +import eventRouter from './modules/event/event.routes'; +import path from 'path'; +import { connectRedis } from './utils/redis'; +import ticketRouter from './modules/tickets/ticket.route'; +import uploadRouter from './modules/user/upload.routes'; +import boostRouter from './modules/boosts/boost.routes'; +import adminRouter from './modules/admin/admin.routes'; const app = express(); -app.use(cors()); +app.use( + cors({ + origin: true, + credentials: true, + }), +); +app.use('/upload', uploadRouter); + app.use(express.json()); app.use( - pinoHttp({ logger, autoLogging: { ignore: (req) => req.url === 'health' } }), + express.urlencoded({ + extended: true, + type: 'application/x-www-form-urlencoded', + }), ); + +app.disable('etag'); +connectRedis(); +app.use( + pinoHttp({ + logger, + autoLogging: { ignore: (req) => req.url === '/health' }, + serializers: { + req: () => undefined, + res: () => undefined, + }, + customSuccessMessage: (req, res) => + `${req.method} ${req.url} ${res.statusCode}`, + }), +); + +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + app.use('/health', Healthrouter); app.use('/auth', authRouter); +app.use('/event', eventRouter); app.use('/user', userRouter); +app.use('/ticket', ticketRouter); +app.use('/boost', boostRouter); +app.use('/admin', adminRouter); + app.use(notFound); app.use(errorHandler); export default app; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..0158cbd --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,56 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +function required(name: string): string { + const value = process.env[name]; + + if (!value) { + throw new Error( + `Missing required enviornment variable: ${name}\n` + + `check your .env file`, + ); + } + + return value; +} + +function optional(name: string, defaultvalue?: string): string | undefined { + const value = process.env[name]; + + if (value === undefined || value === '') { + return defaultvalue; + } + return value; +} + +export const env = { + NODE_ENV: optional('NODE_ENV', 'development'), + PORT: Number(optional('PORT', '4000')), + + DATABASE_URL: required('DATABASE_URL'), + + R2_ENDPOINT: required('R2_ENDPOINT'), + R2_ACCESS_KEY_ID: required('R2_ACCESS_KEY_ID'), + R2_SECRET_ACCESS_KEY: required('R2_SECRET_ACCESS_KEY'), + R2_BUCKET_NAME: required('R2_BUCKET_NAME'), + R2_PUBLIC_URL: required('R2_PUBLIC_URL'), + + ACCESS_TOKEN_SECRET: required('ACCESS_TOKEN_SECRET'), + PASSWORD_RESET_SECRET: required('PASSWORD_RESET_SECRET'), + + FRONTEND_URL: required('FRONTEND_URL'), + + MAILGUN_API_KEY: optional('MAILGUN_API_KEY'), + MAILGUN_DOMAIN: optional('MAILGUN_DOMAIN'), + MAIL_FROM_EMAIL: optional('MAIL_FROM_EMAIL'), + + TWILIO_ACCOUNT_SID: optional('TWILIO_ACCOUNT_SID'), + TWILIO_AUTH_TOKEN: optional('TWILIO_AUTH_TOKEN'), + TWILIO_FROM_NUMBER: optional('TWILIO_FROM_NUMBER'), + + RAZORPAY_KEY_ID: optional('RAZORPAY_KEY_ID'), + RAZORPAY_KEY_SECRET: optional('RAZORPAY_KEY_SECRET'), + + RABBITMQ_URL: optional('RABBITMQ_URL'), +}; diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts index 9fe08ee..7cf5d8f 100644 --- a/backend/src/data-source.ts +++ b/backend/src/data-source.ts @@ -3,15 +3,32 @@ import { DataSource } from 'typeorm'; import { User } from './entities/User'; import { Otp } from './entities/otp'; import { RefreshTokenEntity } from './entities/refreshToken'; -if (!process.env.DATABASE_URL) { +import { Events } from './entities/Event'; +import { EventImage } from './entities/EventImage'; +import { EventTicket } from './entities/Tickets'; +import { Boost } from './entities/Boost'; +import { env } from './config/env'; +import { Admin } from './entities/Admin'; + +if (!env.DATABASE_URL) { throw new Error('DATABASE_URL is not defined'); } export const appDataSource = new DataSource({ type: 'postgres', - url: process.env.DATABASE_URL, + url: env.DATABASE_URL, ssl: { rejectUnauthorized: false, }, - entities: [User, Otp, RefreshTokenEntity], + entities: [ + User, + Otp, + RefreshTokenEntity, + Events, + EventImage, + EventTicket, + Boost, + Admin, + ], synchronize: true, + migrations: ['src/migrations/*.ts'], }); diff --git a/backend/src/entities/Admin.ts b/backend/src/entities/Admin.ts new file mode 100644 index 0000000..b9b897a --- /dev/null +++ b/backend/src/entities/Admin.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('admins') +export class Admin { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ unique: true }) + email!: string; + + @Column() + passwordHash!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/entities/Boost.ts b/backend/src/entities/Boost.ts new file mode 100644 index 0000000..0ceb69a --- /dev/null +++ b/backend/src/entities/Boost.ts @@ -0,0 +1,42 @@ +import { + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Entity, + ManyToOne, +} from 'typeorm'; + +import { Events } from './Event'; +import { User } from './User'; +@Entity('boosts') +export class Boost { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => Events, { onDelete: 'CASCADE' }) + event!: Events; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; + + @Column({ type: 'timestamptz' }) + startTime!: Date; + + @Column({ type: 'timestamptz' }) + endTime!: Date; + + @Column({ default: 'active' }) + status!: 'active' | 'expired' | 'cancelled'; + + @Column() + paymentId!: string; + + @Column('decimal') + amount!: number; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ default: 0 }) + impressions!: number; +} diff --git a/backend/src/entities/Event.ts b/backend/src/entities/Event.ts new file mode 100644 index 0000000..6224ad4 --- /dev/null +++ b/backend/src/entities/Event.ts @@ -0,0 +1,67 @@ +import { + PrimaryGeneratedColumn, + Column, + Entity, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { User } from './User'; +import { EventImage } from './EventImage'; +@Entity() +export class Events { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'timestamptz' }) + startDate!: Date; + + @Column({ type: 'timestamptz' }) + endDate!: Date; + + @ManyToOne(() => User, (user) => user.events, { onDelete: 'CASCADE' }) + user!: User; + + @OneToMany(() => EventImage, (image) => image.event, { + cascade: true, + }) + image!: EventImage[]; + + @Column({ type: 'int' }) + capacity!: number; + + @Column({ type: 'int', default: 0 }) + bookedSeats!: number; + + @Column() + location!: string; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + price!: number; + + @Column({ default: true }) + isFree!: boolean; + + @Column() + category!: string; + + @Column({ type: 'text', nullable: true }) + rules?: string; + + @Column({ default: 'draft' }) + status!: 'draft' | 'published' | 'canceled'; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/entities/EventAttendance.ts b/backend/src/entities/EventAttendance.ts new file mode 100644 index 0000000..591cf9d --- /dev/null +++ b/backend/src/entities/EventAttendance.ts @@ -0,0 +1,26 @@ +import { + PrimaryGeneratedColumn, + Entity, + ManyToOne, + CreateDateColumn, +} from 'typeorm'; +import { Events } from './Event'; +import { User } from './User'; +import { EventTicket } from './Tickets'; +@Entity('EventAttendace') +export class EventAttendace { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => Events, { onDelete: 'CASCADE' }) + event!: Events; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; + + @ManyToOne(() => EventTicket, { onDelete: 'CASCADE' }) + ticket!: EventTicket; + + @CreateDateColumn() + scannedAt!: Date; +} diff --git a/backend/src/entities/EventImage.ts b/backend/src/entities/EventImage.ts new file mode 100644 index 0000000..7d21712 --- /dev/null +++ b/backend/src/entities/EventImage.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, +} from 'typeorm'; +import { Events } from './Event'; + +@Entity('event_images') +export class EventImage { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + imageUrl!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Events, (events) => events.image, { + onDelete: 'CASCADE', + }) + event!: Events; +} diff --git a/backend/src/entities/Tickets.ts b/backend/src/entities/Tickets.ts new file mode 100644 index 0000000..9c28b77 --- /dev/null +++ b/backend/src/entities/Tickets.ts @@ -0,0 +1,43 @@ +import { + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + Unique, + Index, + Entity, +} from 'typeorm'; +import { Events } from './Event'; +import { User } from './User'; + +export enum TicketStatus { + ACTIVE = 'ACTIVE', + USED = 'USED', + CANCELLED = 'CANCELLED', +} + +@Entity('event_tickets') +@Unique(['event', 'user']) +export class EventTicket { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => Events, { onDelete: 'CASCADE' }) + event!: Events; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; + + @Index() + @Column() + qrCode!: string; + + @Column({ + type: 'enum', + enum: TicketStatus, + default: TicketStatus.ACTIVE, + }) + status!: TicketStatus; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/entities/User.ts b/backend/src/entities/User.ts index 53d7e3f..db64d7e 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/entities/User.ts @@ -3,17 +3,34 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + OneToMany, + DeleteDateColumn, + UpdateDateColumn, + Index, } from 'typeorm'; +import { Events } from './Event'; +import { EventTicket } from './Tickets'; + +// export enum UserRole { +// USER = 'USER', +// ADMIN = 'ADMIN', +// } + +export enum UserStatus { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', + BANNED = 'BANNED', +} @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ unique: true, nullable: true }) + @Column({ unique: true }) email!: string; - @Column({ unique: true, nullable: true }) + @Column({ unique: true }) phoneNumber!: string; @Column() @@ -31,12 +48,53 @@ export class User { @Column({ nullable: true }) profileImageUrl?: string; - @Column({ nullable: true }) - passwordHash?: string; + @Column({ nullable: false }) + passwordHash!: string; @Column({ default: false }) isPhoneVerified!: boolean; + @Column({ default: false }) + isEmailVerified!: boolean; + + @OneToMany(() => Events, (event) => event.user) + events!: Events[]; + + @OneToMany(() => EventTicket, (ticket) => ticket.user) + eventTickets!: EventTicket[]; + @CreateDateColumn() createdAt!: Date; + + @Column({ nullable: true }) + passwordResetToken?: string; + + @Column({ type: 'timestamp', nullable: true }) + passwordResetExpires?: Date; + + // @Index() + // @Column({ enum: UserRole, default: UserRole.USER }) + // role!: UserRole; + + @Index() + @Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE }) + status!: UserStatus; + + @Column({ nullable: true }) + banReason?: string; + + @Column({ type: 'timestamp', nullable: true }) + bannedAt?: Date; + + @Column({ type: 'timestamp', nullable: true }) + banExpires?: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isFullyVerified!: boolean; } diff --git a/backend/src/entities/otp.ts b/backend/src/entities/otp.ts index 7e674b3..af856b5 100644 --- a/backend/src/entities/otp.ts +++ b/backend/src/entities/otp.ts @@ -3,20 +3,39 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + Index, } from 'typeorm'; @Entity('otps') export class Otp { @PrimaryGeneratedColumn('uuid') id!: string; + + @Index() @Column() phoneNumber!: string; + @Column() otp!: string; + @Column({ type: 'timestamp' }) expiresAt!: Date; + @Column({ default: false }) verified!: boolean; + + @Column({ default: false }) + consumed!: boolean; + + @Column({ default: 0 }) + attempts!: number; + @CreateDateColumn() createdAt!: Date; + + @Column({ default: false }) + sent!: boolean; + + @Column({ unique: true }) + requestId!: string; } diff --git a/backend/src/messaging/jobTypes.ts b/backend/src/messaging/jobTypes.ts index 95d49b8..a456f85 100644 --- a/backend/src/messaging/jobTypes.ts +++ b/backend/src/messaging/jobTypes.ts @@ -1,5 +1,7 @@ export interface SendOtpJob { - phone: string; - otp: string; + phone: string; + otp: string; purpose: 'login' | 'register' | 'reset_password'; -} \ No newline at end of file + retryCount: number; + requestId: string; +} diff --git a/backend/src/messaging/rabbitmq/connect.ts b/backend/src/messaging/rabbitmq/connect.ts index 375874e..568dd40 100644 --- a/backend/src/messaging/rabbitmq/connect.ts +++ b/backend/src/messaging/rabbitmq/connect.ts @@ -1,36 +1,48 @@ -import * as amqp from "amqplib"; -import { Channel } from "amqplib"; -import { QUEUES } from "./queues"; +import * as amqp from 'amqplib'; +import { QUEUES } from './queues'; +import { env } from '../../config/env'; -const RABBITMQ_URL = - process.env.RABBITMQ_URL || "amqp://guest:guest@localhost:5672"; +const RABBITMQ_URL = env.RABBITMQ_URL || 'amqp://guest:guest@localhost:5672'; -let channel: Channel | null = null; +let connection: Awaited> | undefined; +let channel: amqp.Channel | undefined; export const connectRabbitMQ = async (): Promise => { if (channel) return; try { - console.log("πŸ”Œ Connecting to RabbitMQ..."); + console.log('Connecting to RabbitMQ...'); + + connection = await amqp.connect(RABBITMQ_URL); + + connection.on('close', () => { + console.error('RabbitMQ connection closed. Reconnecting...'); + channel = undefined; + connection = undefined; + setTimeout(connectRabbitMQ, 5000); + }); + + connection.on('error', (err) => { + console.error('RabbitMQ error:', err); + }); - const connection = await amqp.connect(RABBITMQ_URL); channel = await connection.createChannel(); - // declare queues ONCE for (const queue of Object.values(QUEUES)) { await channel.assertQueue(queue, { durable: true }); } - console.log("RabbitMQ connected"); + console.log('RabbitMQ connected'); + console.log('Connection object:', connection?.constructor.name); } catch (err) { - console.error("RabbitMQ connection failed", err); - process.exit(1); + console.error('RabbitMQ connect failed:', err); + setTimeout(connectRabbitMQ, 5000); } }; -export const getChannel = (): Channel => { +export const getChannel = (): amqp.Channel => { if (!channel) { - throw new Error("RabbitMQ channel not initialized"); + throw new Error('RabbitMQ not initialized'); } return channel; }; diff --git a/backend/src/messaging/rabbitmq/consume/index.ts b/backend/src/messaging/rabbitmq/consume/index.ts deleted file mode 100644 index 482524e..0000000 --- a/backend/src/messaging/rabbitmq/consume/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { connectRabbitMQ, getChannel } from "../connect"; -import { QUEUES } from "../queues"; -import { SendOtpJob } from "../../jobTypes"; - -const startOtpWorker = async () => { - await connectRabbitMQ(); - const channel = getChannel(); - - channel.prefetch(1); // process one OTP at a time - - console.log("OTP Worker running"); - - channel.consume(QUEUES.SEND_OTP, async (msg) => { - if (!msg) return; - - try { - const job: SendOtpJob = JSON.parse(msg.content.toString()); - - console.log(`Sending OTP ${job.otp} to ${job.phone}`); - - // Here is where Twilio / SMS provider goes - await new Promise((r) => setTimeout(r, 1500)); - - channel.ack(msg); - console.log("OTP sent"); - } catch (err) { - console.error("OTP failed", err); - channel.nack(msg, false, false); - } - }); -}; - -startOtpWorker(); diff --git a/backend/src/messaging/rabbitmq/consume/otp.worker.ts b/backend/src/messaging/rabbitmq/consume/otp.worker.ts new file mode 100644 index 0000000..252ccee --- /dev/null +++ b/backend/src/messaging/rabbitmq/consume/otp.worker.ts @@ -0,0 +1,110 @@ +import { connectRabbitMQ, getChannel } from '../connect'; +import { QUEUES } from '../queues'; +import { SendOtpJob } from '../../jobTypes'; +import { sendOtpSms } from '../../../Services/sms.service'; +import { appDataSource } from '../../../data-source'; +import { Otp } from '../../../entities/otp'; +import { ConsumeMessage } from 'amqplib'; + +const MAX_RETRIES = 3; + +const startOtpWorker = async () => { + await appDataSource.initialize(); + console.log('Worker DB connected'); + + await connectRabbitMQ(); + const channel = getChannel(); + const otpRepo = appDataSource.getRepository(Otp); + + channel.prefetch(1); + console.log('OTP Worker running'); + + channel.consume(QUEUES.SEND_OTP, async (msg: ConsumeMessage | null) => { + if (!msg) return; + + try { + let job: SendOtpJob; + + try { + job = JSON.parse(msg.content.toString()); + } catch { + console.error('Invalid message format'); + channel.ack(msg); + return; + } + + const otpRecord = await otpRepo.findOne({ + where: { requestId: job.requestId }, + }); + + if (!otpRecord || otpRecord.sent) { + channel.ack(msg); + return; + } + + if (otpRecord.expiresAt < new Date()) { + console.log('OTP expired before sending, skipping...'); + channel.ack(msg); + return; + } + + try { + console.log( + `Sending OTP to ${job.phone} (attempt ${job.retryCount + 1})`, + ); + + await sendOtpSms(job.phone, job.otp); + + otpRecord.sent = true; + await otpRepo.save(otpRecord); + + console.log('OTP sent successfully'); + channel.ack(msg); + } catch (err) { + console.error('OTP sending failed', err); + + if ((job.retryCount ?? 0) < MAX_RETRIES) { + const retryJob: SendOtpJob = { + ...job, + retryCount: (job.retryCount ?? 0) + 1, + }; + + try { + channel.sendToQueue( + QUEUES.SEND_OTP, + Buffer.from(JSON.stringify(retryJob)), + { persistent: true }, + ); + + console.log(`Retry queued (attempt ${retryJob.retryCount})`); + channel.ack(msg); + } catch (enqueueErr) { + console.error('Retry enqueue failed', enqueueErr); + return; + } + } else { + try { + channel.sendToQueue( + QUEUES.SEND_OTP_DLQ, + Buffer.from(JSON.stringify(job)), + { persistent: true }, + ); + + console.error( + `OTP moved to DLQ after ${job.retryCount} retries for ${job.phone}`, + ); + + channel.ack(msg); + } catch (dlqErr) { + console.error('DLQ enqueue failed', dlqErr); + return; + } + } + } + } catch (err) { + console.error('Worker error', err); + } + }); +}; + +startOtpWorker(); diff --git a/backend/src/messaging/rabbitmq/publish.ts b/backend/src/messaging/rabbitmq/publish.ts index e01acad..497acb3 100644 --- a/backend/src/messaging/rabbitmq/publish.ts +++ b/backend/src/messaging/rabbitmq/publish.ts @@ -1,17 +1,32 @@ -import { getChannel } from "./connect"; -import { QUEUES, QueueKey } from "./queues"; +import { connectRabbitMQ, getChannel } from './connect'; +import { QUEUES, QueueKey } from './queues'; export const publish = async ( queue: QueueKey, - payload: T + payload: T, ): Promise => { - const channel = getChannel(); + try { + await connectRabbitMQ(); - channel.sendToQueue( - QUEUES[queue], - Buffer.from(JSON.stringify(payload)), - { persistent: true } - ); + const channel = getChannel(); - console.log("πŸ“€ OTP job queued"); + if (!channel) { + throw new Error('RabbitMQ channel is not available'); + } + + const sent = channel.sendToQueue( + QUEUES[queue], + Buffer.from(JSON.stringify(payload)), + { persistent: true }, + ); + + if (!sent) { + throw new Error('Failed to publish message to RabbitMQ'); + } + + console.log(`Job published to ${queue}`); + } catch (err) { + console.error('Publish failed: ', err); + throw err; + } }; diff --git a/backend/src/messaging/rabbitmq/queues.ts b/backend/src/messaging/rabbitmq/queues.ts index 14ec5ac..89de194 100644 --- a/backend/src/messaging/rabbitmq/queues.ts +++ b/backend/src/messaging/rabbitmq/queues.ts @@ -1,5 +1,6 @@ export const QUEUES = { - SEND_OTP: "send_otp_queue", + SEND_OTP: 'send_otp_queue', + SEND_OTP_DLQ: 'send_otp_dlq', } as const; export type QueueKey = keyof typeof QUEUES; diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 8fa316f..d33d475 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -1,9 +1,19 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { logger } from '../utils/logger'; +import { env } from '../config/env'; +import { appDataSource } from '../data-source'; +import { Admin } from '../entities/Admin'; + +interface TokenPayload { + id: string; + type: 'USER' | 'ADMIN'; +} interface AuthRequest extends Request { user?: { id: string; + type: 'USER' | 'ADMIN'; }; } @@ -21,15 +31,59 @@ export const requireAuth = ( const token = authHeader.split(' ')[1]; try { - const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!) as { - userId: string; - }; + const decoded = jwt.verify(token, env.ACCESS_TOKEN_SECRET!) as TokenPayload; + + if (decoded.type !== 'USER') { + return res.status(403).json({ message: 'User access required' }); + } - req.user = { id: decoded.userId }; + req.user = { id: decoded.id, type: decoded.type }; next(); } catch (err) { + logger.error('catch in requre auth worked'); return res .status(401) .json({ message: 'Invalid or expired token', error: err }); } }; +export const requireAdmin = async ( + req: AuthRequest, + res: Response, + next: NextFunction, +) => { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + + try { + const decoded = jwt.verify(token, env.ACCESS_TOKEN_SECRET!) as TokenPayload; + + if (decoded.type !== 'ADMIN') { + return res.status(403).json({ message: 'Admin access required' }); + } + + const adminRepo = appDataSource.getRepository(Admin); + + const admin = await adminRepo.findOne({ + where: { id: decoded.id }, + }); + + if (!admin) { + return res.status(403).json({ message: 'Admin not found' }); + } + + req.user = { + id: admin.id, + type: 'ADMIN', + }; + + next(); + } catch (err) { + logger.error({ message: 'Admin JWT verification failed', error: err }); + return res.status(401).json({ message: 'Invalid or expired token' }); + } +}; diff --git a/backend/src/middleware/upload.ts b/backend/src/middleware/upload.ts index 971a479..cf6d7bd 100644 --- a/backend/src/middleware/upload.ts +++ b/backend/src/middleware/upload.ts @@ -5,4 +5,10 @@ export const upload = multer({ limits: { fileSize: 5 * 1024 * 1024, }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith('image/')) { + return cb(new Error('Only images are allowed')); + } + cb(null, true); + }, }); diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..1f095bf --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; + +export const validateBody = + (schema: z.ZodSchema) => + (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req.body); + + if (!result.success) { + const firstIssue = result.error.issues[0]; + + return res.status(400).json({ + success: false, + message: firstIssue.message, + field: firstIssue.path[0] ?? null, + }); + } + + req.body = result.data; + next(); + }; + +// export const validateQuery = +// (schema: z.ZodSchema) => +// (req: Request, res: Response, next: NextFunction) => { +// const result = schema.safeParse(req.query); + +// if (!result.success) { +// return res.status(400).json({ +// success: false, +// errors: result.error, +// }); +// } + +// req.query = result.data as any; +// next(); +// }; diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..1f54379 --- /dev/null +++ b/backend/src/modules/admin/admin.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import adminAuthRouter from './auth/authRouter'; +import adminUserRouter from './user/user.routes'; + +const adminRouter = Router(); + +adminRouter.use('/auth', adminAuthRouter); + +adminRouter.use('/users', adminUserRouter); + +export default adminRouter; diff --git a/backend/src/modules/admin/auth/authRouter.ts b/backend/src/modules/admin/auth/authRouter.ts new file mode 100644 index 0000000..e0e0969 --- /dev/null +++ b/backend/src/modules/admin/auth/authRouter.ts @@ -0,0 +1,7 @@ +import express from 'express'; +import { adminLogin } from './authcontroller'; +const adminAuthRouter = express.Router(); + +adminAuthRouter.post('/login', adminLogin); + +export default adminAuthRouter; diff --git a/backend/src/modules/admin/auth/authcontroller.ts b/backend/src/modules/admin/auth/authcontroller.ts new file mode 100644 index 0000000..817fd01 --- /dev/null +++ b/backend/src/modules/admin/auth/authcontroller.ts @@ -0,0 +1,107 @@ +import { Request, Response } from 'express'; +import { appDataSource } from '../../../data-source'; +// import { User, UserRole } from '../../../entities/User'; +import { logger } from '../../../utils/logger'; +import { signAccessToken } from '../../../Services/jwt.service'; +import { Admin } from '../../../entities/Admin'; + +const adminRepo = appDataSource.getRepository(Admin); + +export const adminLogin = async (req: Request, res: Response) => { + try { + logger.info('reached here at admin login'); + + const { email, password } = req.body; + console.log('admin login attempt with email:', email); + console.log('admin login attempt with password:', password); + if (!email || !password) { + return res.status(400).json({ message: 'Email and password required' }); + } + + const admin = await adminRepo.findOne({ where: { email } }); + console.log('admin found:', admin); + if (!admin) { + return res.status(401).json({ message: 'no admin found' }); + } + + // const isMatch = await bcrypt.compare(password, admin.passwordHash); + console.log('password match result:', password === admin.passwordHash); + if (password !== admin.passwordHash) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + const token = signAccessToken({ id: admin.id, type: 'ADMIN' }); + console.log('generated token:', token); + res.status(200).json({ success: true, token }); + } catch (err) { + console.error(err); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// const adminRepo = appDataSource.getRepository(User); + +// export const adminLogin = async (req: Request, res: Response) => { +// try { +// logger.info('reached here at admin login'); + +// const { email, password } = req.body; + +// if (!email || !password) { +// return res.status(400).json({ message: 'Email and password required' }); +// } + +// const admin = await adminRepo.findOne({ where: { email, role: UserRole.ADMIN } }); + +// if (!admin) { +// return res.status(401).json({ message: 'no admin found' }); +// } + +// const isMatch = await bcrypt.compare(password, admin.passwordHash); + +// if (!isMatch) { +// return res.status(401).json({ message: 'Invalid credentials' }); +// } + +// const token = signAccessToken( +// { userId: admin.id, role: admin.role as UserRole.ADMIN }, +// ); + +// res.status(200).json({ success: true, token }); +// } catch (err) { +// console.error(err); +// res.status(500).json({ message: 'Internal server error' }); +// } +// }; + +// export const adminRegister = async (req: Request, res: Response) => { +// try { +// const { name, email, password } = req.body; + +// if (!name || !email || !password) { +// return res.status(400).json({ message: 'Name, email and password required' }); +// } + +// const existingAdmin = await adminRepo.findOne({ where: { email, role: UserRole.ADMIN } }); + +// if (existingAdmin) { +// return res.status(400).json({ message: 'Admin with this email already exists' }); +// } + +// const passwordHash = await bcrypt.hash(password, 10); + +// const newAdmin = adminRepo.create({ +// name, +// email, +// passwordHash, +// role: UserRole.ADMIN, +// }); + +// await adminRepo.save(newAdmin); + +// res.status(201).json({ success: true, message: 'Admin registered successfully' }); +// } catch (err) { +// console.error(err); +// res.status(500).json({ message: 'Internal server error' }); +// } +// }; diff --git a/backend/src/modules/admin/user/user.controller.ts b/backend/src/modules/admin/user/user.controller.ts new file mode 100644 index 0000000..d97fda2 --- /dev/null +++ b/backend/src/modules/admin/user/user.controller.ts @@ -0,0 +1,251 @@ +import { Request, Response } from 'express'; +import { appDataSource } from '../../../data-source'; +import { User, UserStatus } from '../../../entities/User'; +import { IsNull } from 'typeorm'; + +export const listUsers = async (req: Request, res: Response) => { + try { + const { + page = '1', + limit = '20', + status, + role, + search, + } = req.query as { + page?: string; + limit?: string; + status?: string; + role?: string; + search?: string; + }; + + const pageNumber = Math.max(parseInt(page), 1); + const limitNumber = Math.min(Math.max(parseInt(limit), 1), 100); + const skip = (pageNumber - 1) * limitNumber; + + const userRepo = appDataSource.getRepository(User); + + const qb = userRepo + .createQueryBuilder('user') + .where('user.deletedAt IS NULL') + .loadRelationCountAndMap('user.ticketsCount', 'user.eventTickets') + .loadRelationCountAndMap('user.eventsCount', 'user.events'); + + if (status) { + qb.andWhere('user.status = :status', { + status: status.toUpperCase(), + }); + } + + if (role) { + qb.andWhere('user.role = :role', { + role: role.toUpperCase(), + }); + } + + if (search) { + qb.andWhere( + '(LOWER(user.name) LIKE :search OR LOWER(user.email) LIKE :search)', + { search: `%${search.toLowerCase()}%` }, + ); + } + + qb.orderBy('user.createdAt', 'DESC').skip(skip).take(limitNumber); + + const [users, total] = await qb.getManyAndCount(); + + type userWithCounts = User & { + ticketsCount?: number; + eventsCount?: number; + }; + + const typedUsers = users as userWithCounts[]; + + const rawCounts = await userRepo + .createQueryBuilder('user') + .select('user.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('user.deletedAt IS NULL') + .groupBy('user.status') + .getRawMany(); + + const counts = { + all: 0, + active: 0, + inactive: 0, + banned: 0, + }; + + rawCounts.forEach((row) => { + const status = row.status as UserStatus; + const count = Number(row.count); + + counts.all += count; + + if (status === UserStatus.ACTIVE) { + counts.active = count; + } else if (status === UserStatus.INACTIVE) { + counts.inactive = count; + } else if (status === UserStatus.BANNED) { + counts.banned = count; + } + }); + + return res.json({ + counts: { + all: counts.all, + active: counts.active, + inactive: counts.inactive, + banned: counts.banned, + }, + data: typedUsers.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + // role: u.role.toLowerCase(), + joinedAt: u.createdAt, + eventsCount: u.eventsCount ?? 0, + ticketsCount: u.ticketsCount ?? 0, + status: u.status.toLowerCase(), + })), + pagination: { + page: pageNumber, + totalPages: Math.ceil(total / limitNumber), + }, + }); + } catch (error) { + console.error(error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +export const getUserDetails = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo + .createQueryBuilder('user') + .leftJoinAndSelect('user.events', 'event') + .leftJoinAndSelect('user.eventTickets', 'ticket') + .leftJoinAndSelect('ticket.event', 'ticketEvent') + .where('user.id = :id', { id }) + .andWhere('user.deletedAt IS NULL') + .select([ + 'user.id', + 'user.name', + 'user.email', + 'user.phoneNumber', + 'user.age', + 'user.gender', + 'user.interests', + 'user.profileImageUrl', + 'user.isPhoneVerified', + 'user.isEmailVerified', + 'user.role', + 'user.status', + 'user.createdAt', + + 'event.id', + 'event.title', + 'event.startDate', + 'event.endDate', + 'event.location', + + 'ticket.id', + 'ticket.status', + 'ticket.qrCode', + + 'ticketEvent.id', + 'ticketEvent.title', + 'ticketEvent.startDate', + 'ticketEvent.location', + ]) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + return res.status(200).json({ + id: user.id, + name: user.name, + email: user.email, + phoneNumber: user.phoneNumber, + age: user.age, + gender: user.gender, + interests: user.interests, + profileImageUrl: user.profileImageUrl, + isPhoneVerified: user.isPhoneVerified, + isEmailVerified: user.isEmailVerified, + // role: user.role.toLowerCase(), + status: user.status.toLowerCase(), + joinedAt: user.createdAt, + + events: + user.events?.map((e) => ({ + id: e.id, + title: e.title, + startDate: e.startDate, + endDate: e.endDate, + location: e.location, + })) || [], + + tickets: + user.eventTickets?.map((t) => ({ + id: t.id, + status: t.status, + qrCode: t.qrCode, + event: t.event + ? { + id: t.event.id, + title: t.event.title, + startDate: t.event.startDate, + location: t.event.location, + } + : null, + })) || [], + }); + } catch (error) { + console.error(error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +export const toggleUserStatus = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId, deletedAt: IsNull() }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + if (user.status === UserStatus.BANNED) { + return res.status(400).json({ + message: 'Cannot toggle a banned user', + }); + } + + user.status = + user.status === UserStatus.ACTIVE + ? UserStatus.INACTIVE + : UserStatus.ACTIVE; + + await userRepo.save(user); + + return res.json({ + message: 'User status toggled', + status: user.status.toLowerCase(), + }); + } catch (error) { + console.error(error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; diff --git a/backend/src/modules/admin/user/user.routes.ts b/backend/src/modules/admin/user/user.routes.ts new file mode 100644 index 0000000..48b1141 --- /dev/null +++ b/backend/src/modules/admin/user/user.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { getUserDetails, listUsers, toggleUserStatus } from './user.controller'; +import { requireAdmin } from '../../../middleware/auth.middleware'; + +const adminUserRouter = Router(); + +adminUserRouter.get('/', requireAdmin, listUsers); + +adminUserRouter.get('/:id', requireAdmin, getUserDetails); +adminUserRouter.put('/:userId/status', requireAdmin, toggleUserStatus); + +export default adminUserRouter; diff --git a/frontend/screens/profile/UserProfileScreen.tsx b/backend/src/modules/admin/user/user.schema.ts similarity index 100% rename from frontend/screens/profile/UserProfileScreen.tsx rename to backend/src/modules/admin/user/user.schema.ts diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index dcc30fb..ae069f9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,7 +1,5 @@ import { Request, Response, NextFunction } from 'express'; import { logger } from '../../utils/logger'; -import { registerSchema, phoneSchema, loginSchema } from './auth.schema'; -import { sendOtpSms } from '../../Services/sms.service'; import { generateotp } from '../../utils/otp'; import { appDataSource } from '../../data-source'; import { Otp } from '../../entities/otp'; @@ -9,7 +7,19 @@ import { User } from '../../entities/User'; import { signAccessToken } from '../../Services/jwt.service'; import { createRefreshTokenSession } from '../../Services/authToken'; import bcrypt from 'bcrypt'; -// import { publish } from '../../messaging/rabbitmq/publish'; +import { hashRefreshToken } from '../../Services/refreshToken'; +import { publish } from '../../messaging/rabbitmq/publish'; +import { v4 as uuid } from 'uuid'; +import { refreshAccessTokenService } from './auth.service'; +import { RefreshTokenEntity } from '../../entities/refreshToken'; +import { sendLinkEmail, sendOtpEmail } from '../../Services/email.service'; +import { + signPasswordResetToken, + verifyPasswordResetToken, +} from '../../Services/passwordReset.service'; +import { redisClient } from '../../utils/redis'; +import { env } from '../../config/env'; +// import { Admin } from '../../entities/Admin'; export const sendOtp = async ( req: Request, @@ -19,15 +29,7 @@ export const sendOtp = async ( try { logger.info('reached'); - const result = phoneSchema.safeParse(req.body); - if (!result.success) { - return res.status(400).json({ - message: 'validation failed', - error: result.error.format(), - }); - } - - const phoneNumber = result.data.phoneNumber; + const phoneNumber = req.body.phoneNumber.trim(); const userRepo = appDataSource.getRepository(User); const otpRepo = appDataSource.getRepository(Otp); @@ -48,23 +50,42 @@ export const sendOtp = async ( }); } - const otpCode = generateotp(); - logger.debug({ otpCode }, 'otp is'); + const lastOtp = await otpRepo.findOne({ + where: { phoneNumber }, + order: { createdAt: 'DESC' }, + }); + + if (lastOtp && Date.now() - lastOtp.createdAt.getTime() < 60_000) { + return res.status(429).json({ + message: 'Please wait before requesting another OTP', + }); + } + + const otpCode = generateotp().toString(); + logger.debug('OTP generated'); - await sendOtpSms(phoneNumber, otpCode.toString()); + const hashedOtp = await bcrypt.hash(otpCode, 10); - // await publish('SEND_OTP', { - // phone: phoneNumber, - // otp: otpCode.toString(), - // }); + const requestId = uuid(); await otpRepo.delete({ phoneNumber }); + await otpRepo.save({ phoneNumber, - otp: otpCode.toString(), + otp: hashedOtp, + requestId, + sent: false, expiresAt: new Date(Date.now() + 5 * 60 * 1000), }); + await publish('SEND_OTP', { + phone: phoneNumber, + otp: otpCode.toString(), + purpose: 'login', + retryCount: 0, + requestId, + }); + return res.status(200).json({ success: true, message: 'OTP sent successfully', @@ -80,44 +101,65 @@ export const verifyotp = async ( res: Response, next: NextFunction, ) => { - console.log(req.body); - try { const { phoneNumber, otp } = req.body; - if (!otp || !phoneNumber) { - return res - .status(400) - .json({ message: ' otp and phoneNumber are required' }); - } + + const normalizedPhone = phoneNumber.trim(); + const otpInput = String(otp).trim(); + const otpRepo = appDataSource.getRepository(Otp); + const otpRecord = await otpRepo.findOne({ where: { - phoneNumber, - verified: false, + phoneNumber: normalizedPhone, + consumed: false, }, order: { createdAt: 'DESC', }, }); + if (!otpRecord) { return res.status(400).json({ - messge: 'Ivalid or expired OTP', + message: 'Invalid or expired OTP', }); } if (otpRecord.expiresAt < new Date()) { - return res.status(400).json({ message: 'OTP has expired' }); + otpRecord.consumed = true; + await otpRepo.save(otpRecord); + + return res.status(400).json({ + message: 'Invalid or expired OTP', + }); + } + + if (otpRecord.attempts >= 5) { + otpRecord.consumed = true; + await otpRepo.save(otpRecord); + + return res.status(429).json({ + message: 'Too many invalid attempts', + }); } - if (otpRecord.otp != otp.toString()) { - return res.status(400).json({ message: 'Invalid OTP' }); + + const isValid = await bcrypt.compare(otpInput, otpRecord.otp); + + if (!isValid) { + otpRecord.attempts += 1; + await otpRepo.save(otpRecord); + return res.status(400).json({ + message: 'Invalid or expired OTP', + }); } + otpRecord.verified = true; + otpRecord.consumed = true; await otpRepo.save(otpRecord); return res.status(200).json({ success: true, - userExists: false, - message: 'OTP verified, new user', + message: 'OTP verified successfully', otpId: otpRecord.id, }); } catch (err) { @@ -132,14 +174,6 @@ export const register = async ( next: NextFunction, ) => { try { - const result = registerSchema.safeParse(req.body); - if (!result.success) { - return res.status(400).json({ - message: 'invalid registration data', - errors: result.error.format(), - }); - } - const { otpId, name, @@ -149,7 +183,7 @@ export const register = async ( email, password, confirmPassword, - } = result.data; + } = req.body; if (!otpId) { return res.status(400).json({ message: 'otpId required' }); @@ -204,7 +238,8 @@ export const register = async ( await otpRepo.delete({ id: otpId }); const accessToken = signAccessToken({ - userId: user.id, + id: user.id, + type: 'USER', }); const refreshToken = await createRefreshTokenSession(user); @@ -227,48 +262,45 @@ export const login = async ( next: NextFunction, ) => { try { - const result = loginSchema.safeParse(req.body); - if (!result.success) { - return res.status(400).json({ - message: 'Invalid login data', - errors: result.error.format(), - }); - } + // const result = loginSchema.safeParse(req.body); + // if (!result.success) { + // return res.status(400).json({ + // message: 'Invalid login data', + // errors: result.error.format(), + // }); + // } - const { phoneNumber, password } = result.data; + const { phoneNumber, password } = req.body; const userRepo = appDataSource.getRepository(User); - // 2️⃣ find user const user = await userRepo.findOne({ where: { phoneNumber }, }); if (!user) { return res.status(401).json({ - message: 'Invalid phone number or password', + message: 'user not found', }); } - // 3️⃣ block incomplete registration if (!user.passwordHash || !user.isPhoneVerified) { - return res.status(403).json({ - message: 'Account not fully registered', + return res.status(401).json({ + message: 'not verified', }); } - // 4️⃣ compare password const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (!isPasswordValid) { return res.status(401).json({ - message: 'Invalid phone number or password', + message: 'Invalid credentials', }); } - // 5️⃣ issue tokens const accessToken = signAccessToken({ - userId: user.id, + id: user.id, + type: 'USER', }); const refreshToken = await createRefreshTokenSession(user); @@ -283,3 +315,426 @@ export const login = async ( next(err); } }; + +export const logout = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + logger.error('reached thee logout api'); + try { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(400).json({ + message: 'Refresh token required', + }); + } + + const refreshTokenRepo = appDataSource.getRepository(RefreshTokenEntity); + const hashToken = hashRefreshToken(refreshToken); + + await refreshTokenRepo.delete({ + tokenHash: hashToken, + }); + + return res.status(200).json({ + success: true, + message: 'Logged out successfully', + }); + } catch (err) { + logger.error({ err }, 'error in logout'); + next(err); + } +}; + +export const refreshAccessToken = async (req: Request, res: Response) => { + try { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(401).json({ message: 'refresh token missing' }); + } + + const newAccessToken = await refreshAccessTokenService(refreshToken); + + return res.json({ accessToken: newAccessToken }); + } catch (err) { + return res + .status(403) + .json({ message: 'Invalid refresh token', error: err }); + } +}; + +export const forgetPassword = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: 'Email is required' }); + } + + console.log('email' + 'req.body'); + console.log(email, req.body); + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { email }, + }); + + if (!user) { + return res + .status(200) + .json({ message: 'If email exists, reset link sent' }); + } + + console.log('user' + 'userRepo'); + console.log(user, userRepo); + + const resetToken = signPasswordResetToken(user.id); + + const redis = await redisClient.set( + `password_reset:${resetToken}`, + user.id.toString(), + { + EX: 900, + }, + ); + + console.log('redis: ' + redis); + console.log('resetToken: ' + resetToken); + + const resetLink = `${env.FRONTEND_URL}/reset-password?token=${encodeURIComponent(resetToken)}`; + + const m = await sendLinkEmail(email, resetLink); + + console.log('resetLink: ' + resetLink); + console.log('sendpasswordreset email: ' + m); + + return res + .status(200) + .json({ message: 'If account exists, reset link sent' }); + } catch (err) { + console.error(err); + + return res.status(500).json({ message: 'Failed to send reset email' }); + } +}; + +export const resetPassword = async (req: Request, res: Response) => { + try { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ message: 'Token and password required' }); + } + + const decoded = verifyPasswordResetToken(token); + + if (decoded.purpose !== 'password_reset') { + return res.status(400).json({ message: 'Invalid token purpose' }); + } + + const userId = decoded.userId; + + const redisUserId = await redisClient.get(`password_reset:${token}`); + + if (!redisUserId || redisUserId !== userId) { + return res.status(400).json({ message: 'Token is expired or invalid' }); + } + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const samePassword = await bcrypt.compare(newPassword, user.passwordHash!); + + if (samePassword) { + return res.status(400).json({ + message: 'New password cannot be same as old password', + }); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + if (user.passwordHash === hashedPassword) { + return res + .status(400) + .json({ message: "password can't be same as last 3 passwords" }); + } + + user.passwordHash = hashedPassword; + + await userRepo.save(user); + + await redisClient.del(`password_reset:${token}`); + + return res.status(200).json({ message: 'password reset successfully' }); + } catch (err) { + console.error(err); + + return res.status(400).json({ message: 'invalid or expired token' }); + } +}; + +export const changePassword = async (req: Request, res: Response) => { + try { + const userId = req.user?.id; + console.log('USERID:', userId); + console.log('REQ.USERID:', req.user?.id, typeof req.user?.id, req.user); + if (!userId) { + return res.status(401).json({ + message: 'Unauthorized', + }); + } + + const { currentPassword, newPassword, confirmNewPassword } = req.body; + + console.log('BODY:', req.body); + console.log('currentPassword:', currentPassword); + console.log('newPassword:', newPassword); + console.log('confirmNewPassword:', confirmNewPassword); + + if (!currentPassword || !newPassword || !confirmNewPassword) { + return res.status(400).json({ + message: 'All password fields are required', + }); + } + + if (newPassword !== confirmNewPassword) { + return res.status(400).json({ + message: 'New password and confirm new password do not match', + }); + } + + if (newPassword.length < 8) { + return res.status(400).json({ + message: 'Password must be at least 8 characters', + }); + } + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ + message: 'User not found', + }); + } + + console.log('USER:', user); + console.log('passwordHash:', user.passwordHash); + console.log('type:', typeof user.passwordHash); + + const key = `change_password_attempts:${userId}`; + + const isMatch = await bcrypt.compare(currentPassword, user.passwordHash!); + + if (!isMatch) { + const attempts = await redisClient.incr(key); + + // rate limiting + if (attempts === 1) { + await redisClient.expire(key, 300); + } + + if (attempts > 5) { + return res + .status(429) + .json({ message: 'Too many attempts. Try again later.' }); + } + + return res.status(400).json({ + message: 'Current password is incorrect', + }); + } + + const samePassword = await bcrypt.compare(newPassword, user.passwordHash!); + + if (samePassword) { + return res.status(400).json({ + message: 'New password cannot be same as old password', + }); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + user.passwordHash = hashedPassword; + + await userRepo.save(user); + + const refreshTokenRepo = appDataSource.getRepository(RefreshTokenEntity); + + await refreshTokenRepo.delete({ + user: { id: userId }, + }); + + console.log('MATCHED:', isMatch); + console.log('SAMEPASSWORD:', samePassword); + console.log('HASHED:', hashedPassword); + + await redisClient.del(key); + return res.status(200).json({ + success: true, + message: 'Password changed successfully', + }); + } catch (err) { + console.error(err); + + return res.status(500).json({ + message: 'Failed to change password', + }); + } +}; + +export const sendEmailVerificationOtp = async (req: Request, res: Response) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ + message: 'Unauthorized', + }); + } + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId }, + }); + + if (!user || !user.email) { + return res.status(404).json({ + message: 'User not found or email not set', + }); + } + + if (user.isEmailVerified) { + return res.status(400).json({ + message: 'Email already verified', + }); + } + + const sendAttemptKey = `email_verify_send_attempt:${userId}`; + + const sendAttempts = await redisClient.incr(sendAttemptKey); + + if (sendAttempts === 1) { + await redisClient.expire(sendAttemptKey, 30); // 30 sec + } + + if (sendAttempts > 1) { + return res.status(429).json({ + message: 'Please wait before requesting another OTP', + }); + } + + const otpCode = generateotp().toString(); + + const hashedOtp = await bcrypt.hash(otpCode, 10); + + const redisKey = `email_verify:${userId}`; + + await redisClient.set(redisKey, hashedOtp, { + EX: 300, + }); + + const sendOtpEmailResult = await sendOtpEmail(user.email, Number(otpCode)); + console.log('sendOtpEmailResult: ' + sendOtpEmailResult); + + return res.status(200).json({ + success: true, + message: 'Email verification OTP sent successfully', + }); + } catch (err) { + console.error(err); + + return res.status(500).json({ + message: 'Failed to send email verification OTP', + }); + } +}; + +export const verifyEmailOtp = async (req: Request, res: Response) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ + message: 'Unauthorized', + }); + } + + const { otp } = req.body; + + if (!otp) { + return res.status(400).json({ message: 'OTP is required' }); + } + + const redisKey = `email_verify:${userId}`; + const attemptKey = `email_verify_attempts:${userId}`; + + const storedHash = await redisClient.get(redisKey); + + if (!storedHash) { + return res.status(400).json({ message: 'OTP expired or invalid' }); + } + + const attempts = await redisClient.incr(attemptKey); + + if (attempts === 1) { + await redisClient.expire(attemptKey, 300); //5 mins + } + + if (attempts > 5) { + return res + .status(429) + .json({ message: 'Too many attempts. Try again later.' }); + } + + const isValid = await bcrypt.compare(otp.toString(), storedHash); + + if (!isValid) { + return res.status(400).json({ message: 'OTP expired or invalid' }); + } + + const userRepo = appDataSource.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ + message: 'User not found', + }); + } + + user.isEmailVerified = true; + + await userRepo.save(user); + + await redisClient.del(redisKey); + await redisClient.del(attemptKey); + + return res.status(200).json({ + success: true, + message: 'Email verified successfully', + }); + } catch (err) { + console.error(err); + return res.status(500).json({ + message: 'Failed to verify OTP', + }); + } +}; diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index 40c1887..34297f9 100644 --- a/backend/src/modules/auth/auth.routes.ts +++ b/backend/src/modules/auth/auth.routes.ts @@ -1,9 +1,64 @@ -import express from 'express'; -import { sendOtp, verifyotp, register, login } from './auth.controller'; -const authRouter = express.Router(); -authRouter.post('/send-otp', sendOtp); -authRouter.post('/verify-otp', verifyotp); -authRouter.post('/register', register); -authRouter.post('/login', login); +import { Router } from 'express'; +import { + sendOtp, + verifyotp, + register, + login, + logout, + refreshAccessToken, + forgetPassword, + resetPassword, + changePassword, + sendEmailVerificationOtp, + verifyEmailOtp, +} from './auth.controller'; +import { validateBody } from '../../middleware/validate'; +import { + changePasswordSchema, + forgetPasswordSchema, + loginSchema, + phoneSchema, + registerSchema, + resetPasswordSchema, +} from './auth.schema'; +import { requireAuth } from '../../middleware/auth.middleware'; + +const authRouter = Router(); + +authRouter.post('/send-otp', validateBody(phoneSchema), sendOtp); +authRouter.post( + '/verify-otp', + + verifyotp, +); +authRouter.post('/login', validateBody(loginSchema), login); +authRouter.post('/register', validateBody(registerSchema), register); +authRouter.post( + '/refresh-token', + + refreshAccessToken, +); +authRouter.post('/logout', logout); + +authRouter.post( + '/forget-password', + validateBody(forgetPasswordSchema), + forgetPassword, +); +authRouter.post( + '/reset-password', + validateBody(resetPasswordSchema), + resetPassword, +); + +authRouter.put( + '/change-password', + requireAuth, + validateBody(changePasswordSchema), + changePassword, +); + +authRouter.post('/send-otp-email', requireAuth, sendEmailVerificationOtp); +authRouter.post('/verify-otp-email', requireAuth, verifyEmailOtp); export default authRouter; diff --git a/backend/src/modules/auth/auth.schema.ts b/backend/src/modules/auth/auth.schema.ts index f69ff8f..ea5e6c3 100644 --- a/backend/src/modules/auth/auth.schema.ts +++ b/backend/src/modules/auth/auth.schema.ts @@ -1,35 +1,112 @@ -import { z } from 'zod'; +import z from 'zod'; + export const phoneSchema = z.object({ phoneNumber: z.string().min(10, 'Invalid phone number'), }); +export const loginSchema = z + .object({ + phoneNumber: z.string().min(10, 'Phone number is required'), + + password: z.string().min(8, 'Password required'), + }) + .strict(); + export const registerSchema = z .object({ - otpId: z.string().uuid(), + otpId: z.string().uuid('Invalid OTP'), - name: z.string().min(1, 'Name is required'), + name: z + .string() + .min(2, 'Name too short') + .max(50, 'Name too long') + .regex(/^[A-Za-z ]+$/, 'Invalid name') + .trim(), - age: z.number().int().positive().optional(), + age: z.coerce + .number() + .int() + .min(18, 'Must be at least 18') + .max(120, 'Invalid age') + .optional(), gender: z.enum(['male', 'female', 'other']).optional(), - interests: z.array(z.string()).optional(), + interests: z.array(z.string().min(1)).optional(), - email: z.string().email('Invalid email address'), + email: z.string().email('Invalid email').toLowerCase(), - password: z.string().min(8, 'Password must be at least 8 characters'), - - confirmPassword: z + password: z .string() - .min(8, 'Confirm password must be at least 8 characters'), + .min(8) + .regex(/[A-Z]/, 'Must contain uppercase letter') + .regex(/[a-z]/, 'Must contain lowercase letter') + .regex(/[0-9]/, 'Must contain number') + .regex(/[@$!%*?&]/, 'Must contain special character'), + + confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], - }); + }) + .strict(); -export const loginSchema = z.object({ - phoneNumber: z.string().min(10, 'Phone number is required'), +export const forgotPasswordSchema = z.object({ + email: z + .string() + .trim() + .toLowerCase() + .min(1, 'Email is required') + .max(254, 'Email too long') + .email('Invalid email format'), +}); - password: z.string().min(1, 'Password is required'), +export const forgetPasswordSchema = z.object({ + email: z.string().email(), }); + +export const resetPasswordSchema = z.object({ + token: z.string(), + newPassword: z + .string() + .min(8) + .regex(/[A-Z]/, 'Must contain uppercase letter') + .regex(/[a-z]/, 'Must contain lowercase letter') + .regex(/[0-9]/, 'Must contain number') + .regex(/[@$!%*?&]/, 'Must contain special character'), +}); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string(), + + newPassword: z + .string() + .min(8, 'New password must be at least 8 characters') + .max(50, 'Password too long') + .regex(/[A-Z]/, 'Must contain at least one uppercase letter') + .regex(/[a-z]/, 'Must contain at least one lowercase letter') + .regex(/[0-9]/, 'Must contain at least one number') + .regex(/[@$!%*?&#]/, 'Must contain at least one special character'), + + confirmNewPassword: z.string().min(1, 'Please confirm new password'), + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: 'New passwords do not match', + path: ['confirmNewPassword'], + }) + .strict(); + +// export const verifyOtpSchema = z +// .object({ +// phoneNumber: z.string().min(10, 'Invalid phone number'), +// otp: z.string().min(4, 'Invalid OTP'), +// }) +// .strict(); + +// export const refreshTokenSchema = z +// .object({ +// refreshToken: z.string().min(20), +// }) +// .strict(); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index e69de29..d46ca3b 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,23 @@ +import { hashRefreshToken } from '../../Services/refreshToken'; +import { appDataSource } from '../../data-source'; +import { RefreshTokenEntity } from '../../entities/refreshToken'; +import { signAccessToken } from '../../Services/jwt.service'; + +export const refreshAccessTokenService = async (token: string) => { + const tokenHash = hashRefreshToken(token); + const refreshrepo = appDataSource.getRepository(RefreshTokenEntity); + const tokenRecord = await refreshrepo.findOne({ + where: { tokenHash }, + relations: ['user'], + }); + if (!tokenRecord) { + throw new Error('no token record'); + } + if (tokenRecord.expiresAt < new Date()) { + throw new Error(' token is expired'); + } + return signAccessToken({ + userId: tokenRecord.user.id, + role: tokenRecord.user.role, + }); +}; diff --git a/backend/src/modules/boosts/boost.controller.ts b/backend/src/modules/boosts/boost.controller.ts new file mode 100644 index 0000000..34a568c --- /dev/null +++ b/backend/src/modules/boosts/boost.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from 'express'; +import { logger } from '../../utils/logger'; +import { getBoostRepository } from './boost.repository'; +// import { MoreThan } from 'typeorm'; +import { razorpay } from '../payment/razorpay'; +export interface AuthReq extends Request { + user?: { + id: string; + }; +} +export const getBoostEvents = async (req: AuthReq, res: Response) => { + try { + const now = new Date(); + + const boosts = await getBoostRepository + .createQueryBuilder('boost') + .leftJoinAndSelect('boost.event', 'event') + .leftJoinAndSelect('event.image', 'image') + .where('boost.endTime > :now', { now }) + .andWhere('boost.status = :status', { status: 'active' }) + .orderBy('boost.impressions', 'ASC') + .addOrderBy('RANDOM()') + .limit(10) + .getMany(); + + const boostIds = boosts.map((b) => b.id); + + if (boostIds.length) { + await getBoostRepository + .createQueryBuilder() + .update() + .set({ impressions: () => 'impressions + 1' }) + .whereInIds(boostIds) + .execute(); + } + + res.json({ + success: true, + events: boosts.map((b) => b.event), + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: 'internal server err' }); + } +}; + +export const boostEvent = async (req: AuthReq, res: Response) => { + try { + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ message: 'no user id' }); + } + const oneDay = 24 * 60 * 60 * 1000; + const { eventId, duration } = req.body; + logger.info(eventId); + logger.info(duration); + if (!eventId || !duration) { + return res.status(400).json({ message: 'Missing Fields' }); + } + const pricePerPay = 50; + const days = Number(duration); + + if (!days || days < 1 || days > 30) { + return res.status(400).json({ message: 'invalid duration' }); + } + + const amount = days * pricePerPay * 100; + + const link = await razorpay.paymentLink.create({ + amount: amount, + currency: 'INR', + description: 'Event Boost Payment', + + customer: { + name: 'User', + email: 'test@test.com', + contact: '1234567890', + }, + callback_url: 'mysocialcode://payments/success', + callback_method: 'get', + + notify: { + sms: false, + email: false, + }, + + reminder_enable: false, + + notes: { + type: 'boost', + eventId: String(eventId), + duration: String(days), + userId: String(userId), + }, + }); + + res.json({ + url: link.short_url, + }); + } catch (err) { + logger.error({ err }, 'catch in boostEvent worked'); + res.status(500).json({ message: 'order failed' }); + } +}; +//comment diff --git a/backend/src/modules/boosts/boost.repository.ts b/backend/src/modules/boosts/boost.repository.ts new file mode 100644 index 0000000..339dd7b --- /dev/null +++ b/backend/src/modules/boosts/boost.repository.ts @@ -0,0 +1,4 @@ +import { appDataSource } from '../../data-source'; +import { Boost } from '../../entities/Boost'; + +export const getBoostRepository = appDataSource.getRepository(Boost); diff --git a/backend/src/modules/boosts/boost.routes.ts b/backend/src/modules/boosts/boost.routes.ts new file mode 100644 index 0000000..a4cdee8 --- /dev/null +++ b/backend/src/modules/boosts/boost.routes.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { boostEvent, getBoostEvents } from './boost.controller'; +import { requireAuth } from '../../middleware/auth.middleware'; +import { razorPayWebHook } from './webhook'; + +const boostRouter = express.Router(); +boostRouter.post('/purchase', requireAuth, boostEvent); +boostRouter.get('/active', requireAuth, getBoostEvents); +boostRouter.post('/webhook', razorPayWebHook); +export default boostRouter; diff --git a/backend/src/modules/boosts/boost.service.ts b/backend/src/modules/boosts/boost.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/boosts/webhook.ts b/backend/src/modules/boosts/webhook.ts new file mode 100644 index 0000000..0e1e319 --- /dev/null +++ b/backend/src/modules/boosts/webhook.ts @@ -0,0 +1,70 @@ +import { logger } from '../../utils/logger'; +import { getBoostRepository } from './boost.repository'; +import { Request, Response } from 'express'; +import { getEventRepository } from '../event/event.repository'; +import { getUserRepository } from '../user/user.repository'; +import { getTicketRepository } from '../tickets/ticket.repository'; +import { v4 as uuid } from 'uuid'; + +export const razorPayWebHook = async (req: Request, res: Response) => { + console.log('webhook worked'); + + try { + const eventType = req.body.event; + + if (eventType !== 'payment.captured') { + return res.status(200).json({ status: 'ignored' }); + } + + const payment = req.body.payload.payment.entity; + const notes = payment.notes; + + if (notes.type === 'boost') { + const oneDay = 24 * 60 * 60 * 1000; + + const boost = getBoostRepository.create({ + event: { id: notes.eventId }, + user: { id: notes.userId }, + startTime: new Date(), + endTime: new Date(Date.now() + Number(notes.duration) * oneDay), + status: 'active', + paymentId: payment.id, + amount: payment.amount / 100, + }); + + await getBoostRepository.save(boost); + + logger.info('Boost created after payment'); + } + + if (notes.type === 'ticket') { + const event = await getEventRepository.findOne({ + where: { id: notes.eventId }, + }); + + const user = await getUserRepository.findOne({ + where: { id: notes.userId }, + }); + + if (!event || !user) return; + + const ticket = getTicketRepository.create({ + event, + user, + qrCode: `SC${uuid()}`, + }); + + await getTicketRepository.save(ticket); + + event.capacity -= 1; + await getEventRepository.save(event); + + logger.info('Ticket created after payment'); + } + + res.status(200).json({ status: 'ok' }); + } catch (err) { + logger.error({ err }, 'Webhook error'); + res.status(500).json({ message: 'Webhook error' }); + } +}; diff --git a/backend/src/modules/event/event.controller.ts b/backend/src/modules/event/event.controller.ts index e69de29..b84dfb9 100644 --- a/backend/src/modules/event/event.controller.ts +++ b/backend/src/modules/event/event.controller.ts @@ -0,0 +1,549 @@ +import { Request, Response } from 'express'; +import { createEventService } from './event.service'; +import { logger } from '../../utils/logger'; +import { + getEventAttendaceRepository, + getEventRepository, + getImageRepository, +} from './event.repository'; +import { getTicketRepository } from '../tickets/ticket.repository'; +import { v4 as uuid } from 'uuid'; +import { getUserRepository } from '../user/user.repository'; +// import { EventImage } from '../../entities/EventImage'; +import { uploadEventImage } from './event.upload'; +import { appDataSource } from '../../data-source'; +import { redisClient } from '../../utils/redis'; +// import refunds from 'razorpay/dist/types/refunds'; +import { TicketStatus } from '../../entities/Tickets'; +import { razorpay } from '../payment/razorpay'; + +export interface AuthReq extends Request { + user?: { + id: string; + type: 'USER' | 'ADMIN'; + }; +} +export const createEvent = async (req: AuthReq, res: Response) => { + console.log('reached create event'); + console.log(req.body); + console.log('files', req.files); + + try { + const { + title, + description, + startDate, + endDate, + isFree, + price, + location, + capacity, + category, + rules, + } = req.body; + if (!req.user || !req.user.id) { + return res.status(401).json({ + message: 'in side create event controller no req,user if case worked', + }); + } + const userId = req.user?.id; + const files = req.files as Express.Multer.File[]; + const event = await createEventService( + title, + description, + userId, + startDate, + endDate, + isFree, + price, + location, + capacity, + category, + rules, + files, + ); + + res + .status(201) + .json({ message: 'event created', event: event, success: true }); + } catch (err) { + console.log(err); + logger.error({ err }, 'catch in create event worked'); + res.status(400).json({ error: err }); + } +}; + +export const getAllEvents = async (req: AuthReq, res: Response) => { + try { + const limit = Number(req.query.limit) || 10; + const cursor = req.query.cursor as string | undefined; + const cursorId = req.query.id as string | undefined; + + const cacheKey = `events:limit=${limit}:cursor=${cursor || 'none'}:id=${cursorId || 'none'}`; + + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + logger.info('Served from Redis'); + return res.status(200).json(JSON.parse(cachedData)); + } + + const now = new Date(); + + const qb = getEventRepository + .createQueryBuilder('event') + .leftJoinAndSelect('event.image', 'image') + .where('event.status = :status', { status: 'published' }) + .andWhere('event.endDate >= :now', { now }); + + if (cursor && cursorId) { + qb.andWhere( + `(event.startDate > :cursor OR (event.startDate = :cursor AND event.id > :id))`, + { cursor, id: cursorId }, + ); + } + + qb.orderBy('event.startDate', 'ASC') + .addOrderBy('event.id', 'ASC') + .take(limit + 1); + + const events = await qb.getMany(); + + let hasMore = false; + if (events.length > limit) { + hasMore = true; + events.pop(); + } + + const lastEvent = events[events.length - 1]; + + const responseData = { + success: true, + events, + hasMore, + nextCursor: lastEvent + ? { startDate: lastEvent.startDate, id: lastEvent.id } + : null, + }; + + await redisClient.setEx(cacheKey, 60, JSON.stringify(responseData)); + + return res.status(200).json(responseData); + } catch (err) { + return res.status(400).json({ + success: false, + message: 'failed to fetch events', + error: err, + }); + } +}; + +export const getSingleEvent = async (req: AuthReq, res: Response) => { + try { + const id = req.params.id; + const userId = req.user?.id; + + const event = await getEventRepository.findOne({ + where: { id }, + relations: ['image', 'user'], + }); + + if (!event) { + return res.status(404).json({ message: 'Event not found' }); + } + logger.info(event.user.id); + + const host = event.user?.id === userId; + + res.status(200).json({ + message: 'found', + event, + host, + }); + } catch (err) { + console.log('REAL ERROR:', err); + res.status(500).json({ + message: 'Error fetching event', + }); + } +}; + +export const getMyEvents = async (req: AuthReq, res: Response) => { + try { + if (!req.user?.id) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } + + const events = await getEventRepository.find({ + where: { user: { id: req.user.id } }, + relations: ['image', 'user'], + order: { createdAt: 'DESC' }, + }); + + return res.status(200).json({ + success: true, + message: 'My events fetched', + events, + }); + } catch (err) { + logger.error({ err }, 'getMyEvents failed'); + return res + .status(500) + .json({ success: false, message: 'Failed to fetch events' }); + } +}; + +export const joinEvent = async (req: AuthReq, res: Response) => { + try { + const eventId = req.params.id; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const event = await getEventRepository.findOne({ + where: { id: eventId }, + }); + + if (!event) { + return res.status(404).json({ message: 'Event not found' }); + } + + if (event.status !== 'published') { + return res.status(400).json({ message: 'Event not open' }); + } + + if (new Date(event.endDate) < new Date()) { + return res.status(400).json({ message: 'Event ended' }); + } + + if (event.capacity <= 0) { + return res.status(400).json({ message: 'Event full' }); + } + + const existingTicket = await getTicketRepository.findOne({ + where: { + event: { id: eventId }, + user: { id: userId }, + }, + }); + + if (existingTicket) { + return res.status(409).json({ message: 'Already joined' }); + } + + const user = await getUserRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + if (event.isFree) { + logger.info('got inside if event is free'); + const ticket = getTicketRepository.create({ + event, + user, + qrCode: `SC${uuid()}`, + }); + + await getTicketRepository.save(ticket); + + event.capacity -= 1; + await getEventRepository.save(event); + + return res.json({ + success: true, + message: 'Joined free event', + ticket, + }); + } + + logger.error( + 'after the free event just below this is crateing the paid event ', + ); + const link = await razorpay.paymentLink.create({ + amount: event.price * 100, + currency: 'INR', + description: 'Event Ticket', + + customer: { + name: user.name, + email: user.email, + contact: user.phoneNumber, + }, + + notify: { + sms: false, + email: false, + }, + + reminder_enable: false, + + callback_url: 'mysocialcode://payments/success', + callback_method: 'get', + + notes: { + type: 'ticket', + eventId: String(eventId), + userId: String(userId), + }, + }); + + return res.json({ + pay: true, + url: link.short_url, + }); + } catch (err) { + console.error('Join Event Error:', err); + return res.status(500).json({ message: 'Something went wrong' }); + } +}; + +export const updateEvent = async (req: AuthReq, res: Response) => { + try { + const eventId = req.params.id; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const event = await getEventRepository.findOne({ + where: { id: eventId }, + relations: ['image', 'user'], + }); + + if (!event) { + return res.status(404).json({ message: 'Event not found' }); + } + + if (event.user.id !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const { + title, + description, + startDate, + endDate, + location, + capacity, + category, + rules, + existingImages, + isFree, + price, + } = req.body; + + if (title !== undefined) event.title = title; + if (description !== undefined) event.description = description; + if (location !== undefined) event.location = location; + if (category !== undefined) event.category = category; + if (rules !== undefined) event.rules = rules; + + if (capacity !== undefined) { + const parsed = Number(capacity); + if (isNaN(parsed) || parsed < 0) { + return res.status(400).json({ message: 'Invalid capacity' }); + } + event.capacity = parsed; + } + + if (isFree !== undefined) { + event.isFree = isFree === 'true' || isFree === true; + event.price = event.isFree ? 0 : Number(price || 0); + } + + if (startDate !== undefined) { + const d = new Date(startDate); + if (isNaN(d.getTime())) { + return res.status(400).json({ message: 'Invalid startDate' }); + } + event.startDate = d; + } + + if (endDate !== undefined) { + const d = new Date(endDate); + if (isNaN(d.getTime())) { + return res.status(400).json({ message: 'Invalid endDate' }); + } + event.endDate = d; + } + + if (event.startDate && event.endDate && event.endDate < event.startDate) { + return res + .status(400) + .json({ message: 'End date cannot be before start date' }); + } + + let keepImages: string[] = []; + if (existingImages) { + keepImages = Array.isArray(existingImages) + ? existingImages + : JSON.parse(existingImages); + } + + const imagesToDelete = event.image.filter( + (img) => !keepImages.includes(img.imageUrl), + ); + + const files = req.files as Express.Multer.File[] | undefined; + + await appDataSource.transaction(async (manager) => { + if (imagesToDelete.length) { + await manager.remove(imagesToDelete); + } + + await manager.save(event); + + if (files?.length) { + for (const file of files) { + const imageUrl = await uploadEventImage(file); + const image = getImageRepository.create({ imageUrl, event }); + await manager.save(image); + } + } + }); + + return res.status(200).json({ + success: true, + message: 'Event updated', + event, + }); + } catch (err) { + logger.error({ err }, 'Error in updateEvent'); + return res.status(500).json({ + success: false, + message: 'Something went wrong', + }); + } +}; + +export const cancelEvent = async (req: AuthReq, res: Response) => { + try { + const eventId = req.params.id; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const event = await getEventRepository.findOne({ + where: { id: eventId }, + relations: ['user'], + }); + + if (!event) { + return res.status(404).json({ message: 'Event not found' }); + } + + if (event.user.id !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + if (event.status === 'canceled') { + return res.status(400).json({ message: 'Event already canceled' }); + } + + if (event.endDate && new Date(event.endDate) < new Date()) { + return res.status(400).json({ message: 'Cannot cancel a past event' }); + } + + event.status = 'canceled'; + await getEventRepository.save(event); + + return res.status(200).json({ + success: true, + message: 'Event canceled', + event, + }); + } catch (err) { + logger.error({ err }, 'Error in cancelEvent'); + return res.status(500).json({ + success: false, + message: 'Something went wrong', + }); + } +}; + +export const attendance = async (req: AuthReq, res: Response) => { + console.log(req.body); + try { + const { qrCode, eventId } = req.body; + + if (!qrCode || !eventId) { + return res.status(400).json({ + success: false, + message: 'qrCode and eventId are required', + }); + } + + const scan = await getTicketRepository.findOne({ + where: { + qrCode: qrCode, + }, + relations: ['event', 'user'], + }); + + if (!scan) { + return res.status(404).json({ + success: false, + message: 'Invalid ticket', + }); + } + + if (scan.event.id !== eventId) { + return res.status(400).json({ + success: false, + message: 'Ticket not valid for this event', + }); + } + + if (scan.status == TicketStatus.USED) { + return res.status(400).json({ + success: false, + message: 'ticket already used', + }); + } + + scan.status = TicketStatus.USED; + await getTicketRepository.save(scan); + const attendance = getEventAttendaceRepository.create({ + event: scan.event, + user: scan.user, + ticket: scan, + }); + await getEventAttendaceRepository.save(attendance); + return res.status(200).json({ success: true, message: 'entry is allowed' }); + } catch (err) { + logger.error({ err }, 'catch in scan api worked'); + res.status(500).json({ + success: false, + message: 'something bad happend catch in scan api worked', + }); + } +}; + +export const searach = async (req: AuthReq, res: Response) => { + logger.info('reached here at search api'); + try { + const q = req.query.event as string; + logger.info(q); + if (!q || q.trim() === '') { + return res.json([]); + } + const event = await getEventRepository + .createQueryBuilder('event') + .leftJoinAndSelect('event.image', 'image') + .where('event.title ILIKE :q', { q: `%${q}%` }) + .orWhere('event.category ILIKE :q', { q: `%${q}%` }) + .limit(10) + .getMany(); + res.json({ message: 'fetched', events: event }); + } catch (err) { + logger.error({ err }, 'catch in seach worked'); + return res.status(500).json({ message: 'internal server error' }); + } +}; diff --git a/backend/src/modules/event/event.repository.ts b/backend/src/modules/event/event.repository.ts new file mode 100644 index 0000000..7015ede --- /dev/null +++ b/backend/src/modules/event/event.repository.ts @@ -0,0 +1,11 @@ +import { appDataSource } from '../../data-source'; +import { Events } from '../../entities/Event'; +import { EventImage } from '../../entities/EventImage'; +import { EventAttendace } from '../../entities/EventAttendance'; +// import { EventTicket } from '../../entities/Tickets'; +export const getEventRepository = appDataSource.getRepository(Events); + +export const getImageRepository = appDataSource.getRepository(EventImage); + +export const getEventAttendaceRepository = + appDataSource.getRepository(EventAttendace); diff --git a/backend/src/modules/event/event.routes.ts b/backend/src/modules/event/event.routes.ts index e69de29..7a68169 100644 --- a/backend/src/modules/event/event.routes.ts +++ b/backend/src/modules/event/event.routes.ts @@ -0,0 +1,52 @@ +import { Router } from 'express'; +import { + attendance, + cancelEvent, + createEvent, + getAllEvents, + getMyEvents, + getSingleEvent, + joinEvent, + searach, + updateEvent, +} from './event.controller'; + +import { requireAuth } from '../../middleware/auth.middleware'; +import { upload } from '../../middleware/upload'; +import { validateBody } from '../../middleware/validate'; + +import { createEventSchema, updateEventSchema } from './event.schema'; + +const eventRouter = Router(); + +eventRouter.post( + '/create-event', + requireAuth, + upload.array('images', 4), + validateBody(createEventSchema), + createEvent, +); + +eventRouter.get('/all-events', requireAuth, getAllEvents); + +eventRouter.get('/getEvent/:id', requireAuth, getSingleEvent); + +eventRouter.post('/join-event/:id', requireAuth, joinEvent); + +eventRouter.get('/my-events', requireAuth, getMyEvents); + +eventRouter.put( + '/update/:id', + requireAuth, + upload.array('images', 4), + validateBody(updateEventSchema), + updateEvent, +); + +eventRouter.post('/cancel/:id', requireAuth, cancelEvent); + +eventRouter.post('/attendance', requireAuth, attendance); + +eventRouter.get('/search', searach); + +export default eventRouter; diff --git a/backend/src/modules/event/event.schema.ts b/backend/src/modules/event/event.schema.ts new file mode 100644 index 0000000..d075644 --- /dev/null +++ b/backend/src/modules/event/event.schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +export const createEventSchema = z + .object({ + title: z.string().min(3), + + description: z.string().min(10), + + startDate: z.coerce.date(), + + endDate: z.coerce.date(), + + isFree: z.coerce.boolean(), + + price: z + .union([z.coerce.number(), z.literal('')]) + .transform((val) => (val === '' ? undefined : val)) + .optional(), + + location: z.string().min(3), + + capacity: z.coerce.number().int().min(1), + + category: z.string(), + + rules: z.string().optional(), + }) + .refine((data) => data.isFree || data.price !== undefined, { + message: 'Paid events must have price', + path: ['price'], + }) + .refine((data) => data.endDate > data.startDate, { + message: 'End date must be after start date', + path: ['endDate'], + }); + +export const updateEventSchema = z.object({ + title: z.string().min(3).optional(), + + description: z.string().min(10).optional(), + + location: z.string().min(3).optional(), + + capacity: z.coerce.number().int().min(1).optional(), + + category: z.string().optional(), + + rules: z.string().optional(), + + isFree: z.coerce.boolean().optional(), + + price: z.coerce.number().min(0).optional(), + + startDate: z.coerce.date().optional(), + + endDate: z.coerce.date().optional(), +}); diff --git a/backend/src/modules/event/event.service.ts b/backend/src/modules/event/event.service.ts index e69de29..185018d 100644 --- a/backend/src/modules/event/event.service.ts +++ b/backend/src/modules/event/event.service.ts @@ -0,0 +1,58 @@ +import { logger } from '../../utils/logger'; +import { getUserRepository } from '../user/user.repository'; +import { getEventRepository, getImageRepository } from './event.repository'; +import { uploadEventImage } from './event.upload'; +export const createEventService = async ( + title: string, + description: string, + userId: string, + startDate: string, + endDate: string, + isFree: string, + price: string, + location: string, + capacity: string, + category: string, + rules: string, + files: Express.Multer.File[], +) => { + logger.info('iside create event service'); + const user = await getUserRepository.findOne({ + where: { id: userId }, + }); + + if (!user) throw new Error('user not found'); + const parsedIsFree = isFree === 'true'; + const parsedPrice = parsedIsFree ? 0 : Number(price); + const parsedCapacity = Number(capacity); + + const event = getEventRepository.create({ + title, + description, + user, + startDate, + endDate, + isFree: parsedIsFree, + price: parsedPrice, + location, + capacity: parsedCapacity, + category, + rules, + status: 'published', + }); + + await getEventRepository.save(event); + + for (const file of files) { + const imageUrl = await uploadEventImage(file); + + const image = getImageRepository.create({ + imageUrl: imageUrl, + event: event, + }); + + await getImageRepository.save(image); + } + + return event; +}; diff --git a/backend/src/modules/event/event.upload.ts b/backend/src/modules/event/event.upload.ts new file mode 100644 index 0000000..80fbe95 --- /dev/null +++ b/backend/src/modules/event/event.upload.ts @@ -0,0 +1,25 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { r2 } from '../../utils/r2'; +import { env } from '../../config/env'; + +console.log('R2_PUBLIC_URL =', env.R2_PUBLIC_URL); + +export async function uploadEventImage(file: Express.Multer.File) { + console.log('R2_PUBLIC_URL at startup:', env.R2_PUBLIC_URL); + const key = `event/${Date.now()}-${file.originalname}`; + + await r2.send( + new PutObjectCommand({ + Bucket: env.R2_BUCKET_NAME!, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + }), + ); + + return `${env.R2_PUBLIC_URL}/${key}`; + + if (!env.R2_PUBLIC_URL) { + throw new Error('R2_PUBLIC_URL is not defined'); + } +} diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/payment/payment.routes.ts b/backend/src/modules/payment/payment.routes.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/payment/payment.service.ts b/backend/src/modules/payment/payment.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/payment/razorpay.ts b/backend/src/modules/payment/razorpay.ts new file mode 100644 index 0000000..37fc9bc --- /dev/null +++ b/backend/src/modules/payment/razorpay.ts @@ -0,0 +1,6 @@ +import Razorpay from 'razorpay'; +import { env } from '../../config/env'; +export const razorpay = new Razorpay({ + key_id: env.RAZORPAY_KEY_ID!, + key_secret: env.RAZORPAY_KEY_SECRET!, +}); diff --git a/backend/src/modules/payment/webhook.controller.ts b/backend/src/modules/payment/webhook.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/tickets/ticket.controller.ts b/backend/src/modules/tickets/ticket.controller.ts new file mode 100644 index 0000000..1059854 --- /dev/null +++ b/backend/src/modules/tickets/ticket.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import QRCode from 'qrcode'; +import { getTicketRepository } from './ticket.repository'; + +// export interface AuthReq extends Request { +// user?: { +// id: string; +// role: string; +// }; +// } +export const getMyTickets = async (req: Request, res: Response) => { + try { + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const tickets = await getTicketRepository.find({ + where: { + user: { id: userId }, + }, + relations: ['event'], + order: { createdAt: 'DESC' }, + }); + + const ticketsWithQR = await Promise.all( + tickets.map(async (ticket) => { + const qrImage = await QRCode.toDataURL(ticket.qrCode); + + return { + id: ticket.id, + status: ticket.status, + qrCode: ticket.qrCode, + qrImage, + event: { + id: ticket.event.id, + title: ticket.event.title, + startDate: ticket.event.startDate, + location: ticket.event.location, + }, + }; + }), + ); + + return res.status(200).json({ + success: true, + tickets: ticketsWithQR, + }); + } catch (err) { + return res + .status(500) + .json({ message: 'Failed to fetch tickets', error: err }); + } +}; diff --git a/backend/src/modules/tickets/ticket.repository.ts b/backend/src/modules/tickets/ticket.repository.ts new file mode 100644 index 0000000..eb65dab --- /dev/null +++ b/backend/src/modules/tickets/ticket.repository.ts @@ -0,0 +1,4 @@ +import { appDataSource } from '../../data-source'; +import { EventTicket } from '../../entities/Tickets'; + +export const getTicketRepository = appDataSource.getRepository(EventTicket); diff --git a/backend/src/modules/tickets/ticket.route.ts b/backend/src/modules/tickets/ticket.route.ts new file mode 100644 index 0000000..5e6badf --- /dev/null +++ b/backend/src/modules/tickets/ticket.route.ts @@ -0,0 +1,6 @@ +import express from 'express'; +import { getMyTickets } from './ticket.controller'; +import { requireAuth } from '../../middleware/auth.middleware'; +const ticketRouter = express.Router(); +ticketRouter.get('/getMyTickets', requireAuth, getMyTickets); +export default ticketRouter; diff --git a/backend/src/modules/tickets/ticket.service.ts b/backend/src/modules/tickets/ticket.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/modules/user/upload.routes.ts b/backend/src/modules/user/upload.routes.ts new file mode 100644 index 0000000..7591f60 --- /dev/null +++ b/backend/src/modules/user/upload.routes.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { uploadAvatar } from './user.controller'; +import { requireAuth } from '../../middleware/auth.middleware'; +import { upload } from '../../middleware/upload'; + +const uploadRouter = express.Router(); + +uploadRouter.post( + '/avatar', + upload.single('avatar'), + requireAuth, + uploadAvatar, +); + +export default uploadRouter; diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 8876c8f..e683883 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -4,34 +4,137 @@ import { r2 } from '../../utils/r2'; import { appDataSource } from '../../data-source'; import { User } from '../../entities/User'; import { logger } from '../../utils/logger'; -export const uploadAvatar = async ( - req: Request, - res: Response, - next: NextFunction, -) => { +import { env } from '../../config/env'; + +export const uploadAvatar = async (req: Request, res: Response) => { try { - if (!req.file) { - return res.status(400).json({ message: 'Avatar is required' }); + console.log('uploadAvatar controller HIT'); + + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); } - const userId = req.body.userId; // this is temp + const file = req.file; - const key = `avatars/${userId}-${Date.now()}`; + if (!file) { + return res.status(400).json({ message: 'Avatar is required' }); + } + + console.log('uploading to R2...'); + + const key = `avatars/${userId}-${Date.now()}.jpg`; + await r2.send( new PutObjectCommand({ - Bucket: process.env.R2_BUCKET_NAME, + Bucket: env.R2_BUCKET_NAME!, Key: key, Body: file.buffer, ContentType: file.mimetype, }), ); - const imageUrl = `${process.env.R2_ENDPOINT}/${process.env.R2_BUCKET_NAME}/${key}`; + + const imageUrl = `${env.R2_PUBLIC_URL}/${key}`; + // console.log('saving image url to DB:', imageUrl); + + await appDataSource + .getRepository(User) + .update({ id: userId }, { profileImageUrl: imageUrl }); + + console.log('avatar upload complete'); + + return res.status(200).json({ url: imageUrl }); + } catch (err) { + console.error('upload avatar failed:', err); + return res.status(500).json({ message: 'Upload failed' }); + } +}; + +export const getMyProfile = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + if (!req.user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const userId = req.user?.id; + const userRepo = appDataSource.getRepository(User); - await userRepo.update(userId, { profileImageUrl: imageUrl }); - res - .status(200) - .json({ message: 'profile picture uploaded', url: imageUrl }); + + const user = await userRepo.findOne({ + where: { id: userId }, + select: { + id: true, + phoneNumber: true, + name: true, + age: true, + gender: true, + interests: true, + email: true, + profileImageUrl: true, + isPhoneVerified: true, + isEmailVerified: true, + createdAt: true, + }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.status(200).json({ + success: true, + user, + }); + } catch (err) { + logger.error({ err }, 'error in getMyProfile'); + next(err); + } +}; + +export const updateMyProfile = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + if (!req.user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const userId = req.user.id; + + const { name, age, gender, interests, phoneNumber } = req.body; + + const userRepo = appDataSource.getRepository(User); + const user = await userRepo.findOneBy({ id: userId }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + if (phoneNumber !== undefined) user.phoneNumber = phoneNumber; + if (name !== undefined) user.name = name; + if (age !== undefined && age !== '') { + user.age = Number(age); + } + + if (gender !== undefined) user.gender = gender; + + if (Array.isArray(interests)) { + user.interests = interests; + } + + await userRepo.save(user); + + return res.status(200).json({ + success: true, + message: 'Profile updated successfully', + }); } catch (err) { - logger.error({ err }, 'error in upload image'); + logger.error({ err }, 'error updating profile'); next(err); } }; diff --git a/backend/src/modules/user/user.repository.ts b/backend/src/modules/user/user.repository.ts new file mode 100644 index 0000000..3867f00 --- /dev/null +++ b/backend/src/modules/user/user.repository.ts @@ -0,0 +1,3 @@ +import { appDataSource } from '../../data-source'; +import { User } from '../../entities/User'; +export const getUserRepository = appDataSource.getRepository(User); diff --git a/backend/src/modules/user/user.routes.ts b/backend/src/modules/user/user.routes.ts index d657126..68656bf 100644 --- a/backend/src/modules/user/user.routes.ts +++ b/backend/src/modules/user/user.routes.ts @@ -1,6 +1,17 @@ import express from 'express'; import { upload } from '../../middleware/upload'; import { uploadAvatar } from './user.controller'; +import { getMyProfile, updateMyProfile } from './user.controller'; +import { requireAuth } from '../../middleware/auth.middleware'; const userRouter = express.Router(); -userRouter.post('/me/avatar', upload.single('avatar'), uploadAvatar); + +userRouter.put('/me/edit', requireAuth, updateMyProfile); +userRouter.get('/me', requireAuth, getMyProfile); +userRouter.post( + '/me/avatar', + requireAuth, + upload.single('avatar'), + uploadAvatar, +); + export default userRouter; diff --git a/backend/src/server.ts b/backend/src/server.ts index c47fda8..759ea03 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,24 +1,33 @@ import dotenv from 'dotenv'; dotenv.config(); + import app from './app'; import { logger } from './utils/logger'; import { appDataSource } from './data-source'; -if (!process.env.PORT) { +import { connectRabbitMQ } from './messaging/rabbitmq/connect'; +import { env } from './config/env'; + +const PORT = env.PORT; + +if (!PORT) { throw new Error('PORT is not defined in environment variables'); } -(async () => { +const startServer = async () => { try { await appDataSource.initialize(); - logger.info('database connected success fully'); - } catch (error) { - logger.error({ err: error }, 'error connecting the database'); + logger.info('database connected successfully'); + + await connectRabbitMQ(); + logger.info('rabbitmq connected successfully'); + + app.listen(PORT, () => { + logger.info(`server started dont need worry on port ${PORT}`); + }); + } catch (err) { + logger.error({ err }, 'failed to start server'); process.exit(1); } -})(); - -app.listen(process.env.PORT, () => { - logger.info('server started dont need worry'); -}); +}; -//comments +startServer(); diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..06f499a --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,15 @@ +// import { UserRole } from '../entities/User'; + +declare global { + namespace Express { + interface Request { + user?: { + id: string; + // role: UserRole; --- IGNORE --- + type: 'USER' | 'ADMIN'; + }; + } + } +} + +export {}; diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 43c7bb7..93c412c 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -1,6 +1,7 @@ +import { env } from 'node:process'; import pino from 'pino'; -const isDev = process.env.NODE_ENV !== 'production'; +const isDev = env.NODE_ENV !== 'production'; export const logger = pino({ level: process.env.LOG_LEVEL || 'info', diff --git a/backend/src/utils/r2.ts b/backend/src/utils/r2.ts index 10a62ed..8f8e97d 100644 --- a/backend/src/utils/r2.ts +++ b/backend/src/utils/r2.ts @@ -1,9 +1,11 @@ import { S3Client } from '@aws-sdk/client-s3'; +import { env } from '../config/env'; + export const r2 = new S3Client({ region: 'auto', - endpoint: process.env.R2_ENDPOINT, + endpoint: env.R2_ENDPOINT, credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!, - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + accessKeyId: env.R2_ACCESS_KEY_ID!, + secretAccessKey: env.R2_SECRET_ACCESS_KEY!, }, }); diff --git a/backend/src/utils/redis.ts b/backend/src/utils/redis.ts new file mode 100644 index 0000000..c006638 --- /dev/null +++ b/backend/src/utils/redis.ts @@ -0,0 +1,16 @@ +import { createClient } from 'redis'; +import { logger } from './logger'; +export const redisClient = createClient({ + url: 'redis://localhost:6379', +}); + +redisClient.on('error', (err) => { + logger.error('Redis error:', err); +}); + +export async function connectRedis() { + if (!redisClient.isOpen) { + await redisClient.connect(); + logger.info('Redis connected'); + } +} diff --git a/frontend/app.json b/frontend/app.json index 9accf46..9d53b42 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -1,11 +1,11 @@ { "expo": { - "name": "frontend", - "slug": "frontend", + "name": "mysocialcode", + "slug": "mysocialcode", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "frontend", + "scheme": "mysocialcode", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { @@ -19,7 +19,9 @@ "monochromeImage": "./assets/images/android-icon-monochrome.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "permissions": ["android.permission.CAMERA", "android.permission.CAMERA"], + "package": "com.anonymous.mysocialcode" }, "web": { "output": "static", @@ -39,7 +41,9 @@ } } ], - "expo-secure-store" + "expo-secure-store", + "@react-native-community/datetimepicker", + "expo-barcode-scanner" ], "experiments": { "typedRoutes": true, diff --git a/frontend/app/(auth)/forgetPassword.tsx b/frontend/app/(auth)/forgetPassword.tsx new file mode 100644 index 0000000..72c1842 --- /dev/null +++ b/frontend/app/(auth)/forgetPassword.tsx @@ -0,0 +1,4 @@ +import ForgotPasswordScreen from '@/screens/auth/ForgetPasswordScreen'; +export default function ForgetPasswordScreen() { + return ; +} diff --git a/frontend/app/(auth)/intro.tsx b/frontend/app/(auth)/index.tsx similarity index 100% rename from frontend/app/(auth)/intro.tsx rename to frontend/app/(auth)/index.tsx diff --git a/frontend/app/(auth)/reset-password.tsx b/frontend/app/(auth)/reset-password.tsx new file mode 100644 index 0000000..fc84abf --- /dev/null +++ b/frontend/app/(auth)/reset-password.tsx @@ -0,0 +1,5 @@ +import ResetPasswordScreen from '@/screens/auth/ResetPasswordScreen'; + +export default function ResetPasswordPage() { + return ; +} diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index dc88850..d7646bc 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -53,7 +53,17 @@ export default function TabLayout() { ), }} /> + + + + + + + + + + ); } diff --git a/frontend/app/(tabs)/events/[id]/boost.tsx b/frontend/app/(tabs)/events/[id]/boost.tsx new file mode 100644 index 0000000..35cd228 --- /dev/null +++ b/frontend/app/(tabs)/events/[id]/boost.tsx @@ -0,0 +1,4 @@ +import EventBoostScrees from '@/screens/events/BoostEvent'; +export default function BoostScreen() { + return ; +} diff --git a/frontend/app/(tabs)/events/[id]/scan.tsx b/frontend/app/(tabs)/events/[id]/scan.tsx new file mode 100644 index 0000000..e79ce2d --- /dev/null +++ b/frontend/app/(tabs)/events/[id]/scan.tsx @@ -0,0 +1,5 @@ +import EventScan from '@/screens/events/EventScan'; + +export default function Scan() { + return ; +} diff --git a/frontend/app/(tabs)/events/update/[id].tsx b/frontend/app/(tabs)/events/update/[id].tsx new file mode 100644 index 0000000..8d53f92 --- /dev/null +++ b/frontend/app/(tabs)/events/update/[id].tsx @@ -0,0 +1,4 @@ +import UpdateScreen from '../../../../screens/events/EventUpdateScreen'; +export default function EventUpdateScreen() { + return ; +} diff --git a/frontend/app/(tabs)/payments/failed.tsx b/frontend/app/(tabs)/payments/failed.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/(tabs)/payments/pending.tsx b/frontend/app/(tabs)/payments/pending.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/(tabs)/payments/success.tsx b/frontend/app/(tabs)/payments/success.tsx new file mode 100644 index 0000000..374eeea --- /dev/null +++ b/frontend/app/(tabs)/payments/success.tsx @@ -0,0 +1,4 @@ +import PaymentSuccess from '@/screens/payment/success'; +export function SuccessPage() { + return ; +} diff --git a/frontend/app/(tabs)/profile/change-password.tsx b/frontend/app/(tabs)/profile/change-password.tsx new file mode 100644 index 0000000..5cdd568 --- /dev/null +++ b/frontend/app/(tabs)/profile/change-password.tsx @@ -0,0 +1,5 @@ +import ChangePasswordScreen from '@/screens/profile/ChangePasswordScreen'; + +export default function ChangePasswordsScreen() { + return ; +} diff --git a/frontend/app/(tabs)/profile/edit/index.tsx b/frontend/app/(tabs)/profile/edit/index.tsx new file mode 100644 index 0000000..3bd90f6 --- /dev/null +++ b/frontend/app/(tabs)/profile/edit/index.tsx @@ -0,0 +1,4 @@ +import EditProfileScreen from '@/screens/profile/EditProfileScreen'; +export default function EditProfile() { + return ; +} diff --git a/frontend/app/(tabs)/profile/setting.tsx b/frontend/app/(tabs)/profile/setting.tsx new file mode 100644 index 0000000..50e5d37 --- /dev/null +++ b/frontend/app/(tabs)/profile/setting.tsx @@ -0,0 +1,5 @@ +import SettingScreen from '@/screens/profile/SettingScreen'; + +export default function settingsScreen() { + return ; +} diff --git a/frontend/app/(tabs)/profile/verify-email.tsx b/frontend/app/(tabs)/profile/verify-email.tsx new file mode 100644 index 0000000..2f91cc3 --- /dev/null +++ b/frontend/app/(tabs)/profile/verify-email.tsx @@ -0,0 +1,5 @@ +import VerifyEmailScreen from '@/screens/profile/VerifyEmailScreen'; + +export default function VerifyEmailScreens() { + return ; +} diff --git a/frontend/app/(tabs)/tickets/ticket.tsx b/frontend/app/(tabs)/tickets/ticket.tsx new file mode 100644 index 0000000..8010022 --- /dev/null +++ b/frontend/app/(tabs)/tickets/ticket.tsx @@ -0,0 +1,4 @@ +import TicketScreen from '@/screens/ticket/ticketScreen'; +export default function TicketIndex() { + return ; +} diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 6b3d61f..59f8c75 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -1,40 +1,43 @@ import { Stack } from 'expo-router'; -import { useState, useEffect } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect, useState } from 'react'; +import Toast from 'react-native-toast-message'; +import { getAccessToken } from '@/services/token/token.storage'; +import { toastConfig } from '@/utils/toastConfig'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { View, ActivityIndicator } from 'react-native'; export default function RootLayout() { - const [hasSeenInro, setHasSeenIntro] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(null); useEffect(() => { - async function loadIntro() { - const value = await AsyncStorage.getItem('hasSeenIntro'); - setHasSeenIntro(value === 'true'); + async function loadState() { + const token = await getAccessToken(); + setIsAuthenticated(!!token); } - loadIntro(); + + loadState(); }, []); - if (hasSeenInro === null) { - return null; - } - if (!hasSeenInro) { + // Loading screen while checking token + if (isAuthenticated === null) { return ( - - - + + + ); } - if (!isAuthenticated) { - return ( + + return ( + - + {isAuthenticated ? ( + + ) : ( + + )} - ); - } - return ( - - - + + ); } diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx deleted file mode 100644 index 7cf14bb..0000000 --- a/frontend/app/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Text, View } from 'react-native'; -import { Link } from 'expo-router'; - -export default function Index() { - return ( - - Edit app/index.tsx to edit this screen. - intro - - ); -} diff --git a/frontend/assets/images/OIP.jpeg b/frontend/assets/images/OIP.jpeg new file mode 100644 index 0000000..4fecf67 Binary files /dev/null and b/frontend/assets/images/OIP.jpeg differ diff --git a/frontend/assets/onBoarding/onboard1.jpg b/frontend/assets/onBoarding/onboard1.jpg new file mode 100644 index 0000000..68bdf5d Binary files /dev/null and b/frontend/assets/onBoarding/onboard1.jpg differ diff --git a/frontend/assets/onBoarding/onboard2.jpg b/frontend/assets/onBoarding/onboard2.jpg new file mode 100644 index 0000000..8429f7e Binary files /dev/null and b/frontend/assets/onBoarding/onboard2.jpg differ diff --git a/frontend/assets/onBoarding/onboard3.jpg b/frontend/assets/onBoarding/onboard3.jpg new file mode 100644 index 0000000..37c0709 Binary files /dev/null and b/frontend/assets/onBoarding/onboard3.jpg differ diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..d872de3 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; diff --git a/frontend/components/comps/skeletonEvent.tsx b/frontend/components/comps/skeletonEvent.tsx new file mode 100644 index 0000000..91b127e --- /dev/null +++ b/frontend/components/comps/skeletonEvent.tsx @@ -0,0 +1,36 @@ +import { View, StyleSheet } from 'react-native'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Spinner } from '../ui/spinner'; + +export default function EventDetailSkeleton() { + return ( + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + backgroundColor: '#fff', + flex: 1, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 24, + }, +}); diff --git a/frontend/components/ui/action-sheet.tsx b/frontend/components/ui/action-sheet.tsx new file mode 100644 index 0000000..e427dcc --- /dev/null +++ b/frontend/components/ui/action-sheet.tsx @@ -0,0 +1,408 @@ +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { useColor } from '@/hooks/useColor'; +import { CORNERS, FONT_SIZE } from '@/theme/globals'; +import React, { useEffect, useState } from 'react'; +import { + ActionSheetIOS, + Dimensions, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + TouchableOpacity, + ViewStyle, +} from 'react-native'; +import Animated, { + Easing, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +export interface ActionSheetOption { + title: string; + onPress: () => void; + destructive?: boolean; + disabled?: boolean; + icon?: React.ReactNode; +} + +interface ActionSheetProps { + visible: boolean; + onClose: () => void; + title?: string; + message?: string; + options: ActionSheetOption[]; + cancelButtonTitle?: string; + style?: ViewStyle; +} + +export function ActionSheet({ + visible, + onClose, + title, + message, + options, + cancelButtonTitle = 'Cancel', + style, +}: ActionSheetProps) { + // Use iOS native ActionSheet on iOS + if (Platform.OS === 'ios') { + useEffect(() => { + if (visible) { + const optionTitles = options.map((option) => option.title); + const destructiveButtonIndex = options.findIndex( + (option) => option.destructive, + ); + const disabledButtonIndices = options + .map((option, index) => (option.disabled ? index : -1)) + .filter((index) => index !== -1); + + ActionSheetIOS.showActionSheetWithOptions( + { + title, + message, + options: [...optionTitles, cancelButtonTitle], + cancelButtonIndex: optionTitles.length, + destructiveButtonIndex: + destructiveButtonIndex !== -1 + ? destructiveButtonIndex + : undefined, + disabledButtonIndices: + disabledButtonIndices.length > 0 + ? disabledButtonIndices + : undefined, + }, + (buttonIndex) => { + if (buttonIndex < optionTitles.length) { + options[buttonIndex].onPress(); + } + onClose(); + }, + ); + } + }, [visible, title, message, options, cancelButtonTitle, onClose]); + + // Return null for iOS as we use the native ActionSheet + return null; + } + + // Custom implementation for Android and other platforms + return ( + + ); +} + +// Custom ActionSheet implementation for Android using react-native-reanimated +function AndroidActionSheet({ + visible, + onClose, + title, + message, + options, + cancelButtonTitle, + style, +}: ActionSheetProps) { + const [isSheetVisible, setIsSheetVisible] = useState(visible); + const progress = useSharedValue(0); + const screenHeight = Dimensions.get('window').height; + + const cardColor = useColor('card'); + const textColor = useColor('text'); + const mutedColor = useColor('textMuted'); + const borderColor = useColor('border'); + const destructiveColor = useColor('red'); + + useEffect(() => { + if (visible) { + setIsSheetVisible(true); + progress.value = withTiming(1, { + duration: 300, + easing: Easing.out(Easing.quad), + }); + } else { + // Animate out, then set the modal to invisible after the animation is done + progress.value = withTiming( + 0, + { duration: 250, easing: Easing.in(Easing.quad) }, + (finished) => { + if (finished) { + runOnJS(setIsSheetVisible)(false); + } + }, + ); + } + }, [visible, progress]); + + // Animated style for the backdrop + const backdropAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + })); + + // Animated style for the sheet itself (slide up/down) + const sheetAnimatedStyle = useAnimatedStyle(() => { + const translateY = interpolate(progress.value, [0, 1], [screenHeight, 0]); + return { + transform: [{ translateY }], + }; + }); + + const handleOptionPress = (option: ActionSheetOption) => { + if (!option.disabled) { + option.onPress(); + onClose(); + } + }; + + const handleBackdropPress = () => { + onClose(); + }; + + // Render null if the sheet is not supposed to be visible + if (!isSheetVisible) { + return null; + } + + return ( + + + + + + + + {/* Header */} + {(title || message) && ( + + {title && ( + + {title} + + )} + {message && ( + + {message} + + )} + + )} + + {/* Options */} + + {options.map((option, index) => ( + handleOptionPress(option)} + disabled={option.disabled} + activeOpacity={0.6} + > + + {option.icon && ( + {option.icon} + )} + + {option.title} + + + + ))} + + + {/* Cancel Button */} + + + + {cancelButtonTitle} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + backdropPressable: { + flex: 1, + }, + sheet: { + borderTopLeftRadius: CORNERS, + borderTopRightRadius: CORNERS, + paddingBottom: 34, // Safe area bottom padding + maxHeight: '80%', + elevation: 10, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: -2, + }, + shadowOpacity: 0.25, + shadowRadius: 10, + }, + header: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 16, + alignItems: 'center', + }, + title: { + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + marginBottom: 4, + }, + message: { + fontSize: FONT_SIZE - 1, + textAlign: 'center', + lineHeight: 20, + }, + optionsContainer: { + maxHeight: 300, + }, + option: { + borderBottomWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 20, + paddingVertical: 16, + }, + lastOption: { + borderBottomWidth: 0, + }, + disabledOption: { + opacity: 0.5, + }, + optionContent: { + flexDirection: 'row', + alignItems: 'center', + }, + optionIcon: { + marginRight: 12, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, + optionText: { + fontSize: FONT_SIZE, + fontWeight: '500', + flex: 1, + }, + cancelContainer: { + borderTopWidth: StyleSheet.hairlineWidth, + marginTop: 8, + }, + cancelButton: { + paddingHorizontal: 20, + paddingVertical: 16, + alignItems: 'center', + }, + cancelText: { + fontSize: FONT_SIZE, + fontWeight: '600', + }, +}); + +// Hook for easier ActionSheet usage (No changes needed here) +export function useActionSheet() { + const [isVisible, setIsVisible] = React.useState(false); + const [config, setConfig] = React.useState< + Omit + >({ + options: [], + }); + + const show = React.useCallback( + (actionSheetConfig: Omit) => { + setConfig(actionSheetConfig); + setIsVisible(true); + }, + [], + ); + + const hide = React.useCallback(() => { + setIsVisible(false); + }, []); + + const ActionSheetComponent = React.useMemo( + () => , + [isVisible, hide, config], + ); + + return { + show, + hide, + ActionSheet: ActionSheetComponent, + isVisible, + }; +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..bdfbe32 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,392 @@ +import { Icon } from '@/components/ui/icon'; +import { ButtonSpinner, SpinnerVariant } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { useColor } from '@/hooks/useColor'; +import { CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; +import * as Haptics from 'expo-haptics'; +import { LucideProps } from 'lucide-react-native'; +import { forwardRef } from 'react'; +import { + Pressable, + TextStyle, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewStyle, +} from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +export type ButtonVariant = + | 'default' + | 'destructive' + | 'success' + | 'outline' + | 'secondary' + | 'ghost' + | 'link'; + +export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; + +export interface ButtonProps extends Omit { + label?: string; + children?: React.ReactNode; + animation?: boolean; + haptic?: boolean; + icon?: React.ComponentType; + onPress?: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + disabled?: boolean; + loading?: boolean; + loadingVariant?: SpinnerVariant; + style?: ViewStyle | ViewStyle[]; + textStyle?: TextStyle; +} + +export const Button = forwardRef( + ( + { + children, + icon, + onPress, + variant = 'default', + size = 'default', + disabled = false, + loading = false, + animation = true, + haptic = true, + loadingVariant = 'default', + style, + textStyle, + ...props + }, + ref, + ) => { + const primaryColor = useColor('primary'); + const primaryForegroundColor = useColor('primaryForeground'); + const secondaryColor = useColor('secondary'); + const secondaryForegroundColor = useColor('secondaryForeground'); + const destructiveColor = useColor('red'); + const destructiveForegroundColor = useColor('destructiveForeground'); + const greenColor = useColor('green'); + const borderColor = useColor('border'); + + // Animation values for liquid glass effect + const scale = useSharedValue(1); + const brightness = useSharedValue(1); + + const getButtonStyle = (): ViewStyle => { + const baseStyle: ViewStyle = { + borderRadius: CORNERS, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }; + + // Size variants + switch (size) { + case 'sm': + Object.assign(baseStyle, { height: 44, paddingHorizontal: 24 }); + break; + case 'lg': + Object.assign(baseStyle, { height: 54, paddingHorizontal: 36 }); + break; + case 'icon': + Object.assign(baseStyle, { + height: HEIGHT, + width: HEIGHT, + paddingHorizontal: 0, + }); + break; + default: + Object.assign(baseStyle, { height: HEIGHT, paddingHorizontal: 32 }); + } + + // Variant styles + switch (variant) { + case 'destructive': + return { ...baseStyle, backgroundColor: destructiveColor }; + case 'success': + return { ...baseStyle, backgroundColor: greenColor }; + case 'outline': + return { + ...baseStyle, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor, + }; + case 'secondary': + return { ...baseStyle, backgroundColor: secondaryColor }; + case 'ghost': + return { ...baseStyle, backgroundColor: 'transparent' }; + case 'link': + return { + ...baseStyle, + backgroundColor: 'transparent', + height: 'auto', + paddingHorizontal: 0, + }; + default: + return { ...baseStyle, backgroundColor: primaryColor }; + } + }; + + const getButtonTextStyle = (): TextStyle => { + const baseTextStyle: TextStyle = { + fontSize: FONT_SIZE, + fontWeight: '500', + }; + + switch (variant) { + case 'destructive': + return { ...baseTextStyle, color: destructiveForegroundColor }; + case 'success': + return { ...baseTextStyle, color: destructiveForegroundColor }; + case 'outline': + return { ...baseTextStyle, color: primaryColor }; + case 'secondary': + return { ...baseTextStyle, color: secondaryForegroundColor }; + case 'ghost': + return { ...baseTextStyle, color: primaryColor }; + case 'link': + return { + ...baseTextStyle, + color: primaryColor, + textDecorationLine: 'underline', + }; + default: + return { ...baseTextStyle, color: primaryForegroundColor }; + } + }; + + const getColor = (): string => { + switch (variant) { + case 'destructive': + return destructiveForegroundColor; + case 'success': + return destructiveForegroundColor; + case 'outline': + return primaryColor; + case 'secondary': + return secondaryForegroundColor; + case 'ghost': + return primaryColor; + case 'link': + return primaryColor; + default: + return primaryForegroundColor; + } + }; + + // Helper function to get icon size based on button size + const getIconSize = (): number => { + switch (size) { + case 'sm': + return 16; + case 'lg': + return 24; + case 'icon': + return 20; + default: + return 18; + } + }; + + // Trigger haptic feedback + const triggerHapticFeedback = () => { + if (haptic && !disabled && !loading) { + if (process.env.EXPO_OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } + }; + + // Improved animation handlers for liquid glass effect + const handlePressIn = (ev?: any) => { + 'worklet'; + // Trigger haptic feedback + triggerHapticFeedback(); + + // Scale up with bouncy spring animation + scale.value = withSpring(1.05, { + damping: 15, + stiffness: 400, + mass: 0.5, + }); + + // Slight brightness increase for glass effect + brightness.value = withSpring(1.1, { + damping: 20, + stiffness: 300, + }); + + // Call original onPressIn if provided + props.onPressIn?.(ev); + }; + + const handlePressOut = (ev?: any) => { + 'worklet'; + // Return to original size with smooth spring + scale.value = withSpring(1, { + damping: 20, + stiffness: 400, + mass: 0.8, + overshootClamping: false, + }); + + // Return brightness to normal + brightness.value = withSpring(1, { + damping: 20, + stiffness: 300, + }); + + // Call original onPressOut if provided + props.onPressOut?.(ev); + }; + + // Handle actual press action + const handlePress = () => { + if (onPress && !disabled && !loading) { + onPress(); + } + }; + + // Handle press for TouchableOpacity (non-animated version) + const handleTouchablePress = () => { + triggerHapticFeedback(); + handlePress(); + }; + + // Animated styles using useAnimatedStyle + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: scale.value }], + opacity: brightness.value * (disabled ? 0.5 : 1), + }; + }); + + // Extract flex value from style prop + const getFlexFromStyle = () => { + if (!style) return null; + + const styleArray = Array.isArray(style) ? style : [style]; + + // Find the last occurrence of flex (in case of multiple styles with flex) + for (let i = styleArray.length - 1; i >= 0; i--) { + const s = styleArray[i]; + if (s && typeof s === 'object' && 'flex' in s) { + return s.flex; + } + } + return null; + }; + + // Alternative simpler solution - replace flex with alignSelf + const getPressableStyle = (): ViewStyle => { + const flexValue = getFlexFromStyle(); + // If flex: 1 is applied, use alignSelf: 'stretch' instead to only affect width + return flexValue === 1 + ? { + flex: 1, + alignSelf: 'stretch', + } + : flexValue !== null + ? { + flex: flexValue, + maxHeight: size === 'sm' ? 44 : size === 'lg' ? 54 : HEIGHT, + } + : {}; + }; + + // Updated getStyleWithoutFlex function + const getStyleWithoutFlex = () => { + if (!style) return style; + + const styleArray = Array.isArray(style) ? style : [style]; + return styleArray.map((s) => { + if (s && typeof s === 'object' && 'flex' in s) { + const { flex, ...restStyle } = s; + return restStyle; + } + return s; + }); + }; + + const buttonStyle = getButtonStyle(); + const finalTextStyle = getButtonTextStyle(); + const contentColor = getColor(); + const iconSize = getIconSize(); + const styleWithoutFlex = getStyleWithoutFlex(); + + return animation ? ( + + + {loading ? ( + + ) : typeof children === 'string' ? ( + + {icon && ( + + )} + {children} + + ) : ( + + {icon && ( + + )} + {children} + + )} + + + ) : ( + + {loading ? ( + + ) : typeof children === 'string' ? ( + + {icon && } + {children} + + ) : ( + children + )} + + ); + }, +); + +// Add display name for better debugging +Button.displayName = 'Button'; diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx new file mode 100644 index 0000000..7950e18 --- /dev/null +++ b/frontend/components/ui/card.tsx @@ -0,0 +1,110 @@ +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS } from '@/theme/globals'; +import { TextStyle, ViewStyle } from 'react-native'; + +interface CardProps { + children: React.ReactNode; + style?: ViewStyle; +} + +export function Card({ children, style }: CardProps) { + const cardColor = useColor('card'); + const foregroundColor = useColor('text'); + + return ( + + {children} + + ); +} + +interface CardHeaderProps { + children: React.ReactNode; + style?: ViewStyle; +} + +export function CardHeader({ children, style }: CardHeaderProps) { + return {children}; +} + +interface CardTitleProps { + children: React.ReactNode; + style?: TextStyle; +} + +export function CardTitle({ children, style }: CardTitleProps) { + return ( + + {children} + + ); +} + +interface CardDescriptionProps { + children: React.ReactNode; + style?: TextStyle; +} + +export function CardDescription({ children, style }: CardDescriptionProps) { + return ( + + {children} + + ); +} + +interface CardContentProps { + children: React.ReactNode; + style?: ViewStyle; +} + +export function CardContent({ children, style }: CardContentProps) { + return {children}; +} + +interface CardFooterProps { + children: React.ReactNode; + style?: ViewStyle; +} + +export function CardFooter({ children, style }: CardFooterProps) { + return ( + + {children} + + ); +} diff --git a/frontend/components/ui/icon.tsx b/frontend/components/ui/icon.tsx new file mode 100644 index 0000000..61d5f40 --- /dev/null +++ b/frontend/components/ui/icon.tsx @@ -0,0 +1,33 @@ +import { useColor } from '@/hooks/useColor'; +import { LucideProps } from 'lucide-react-native'; +import React from 'react'; + +export type Props = LucideProps & { + lightColor?: string; + darkColor?: string; + name: React.ComponentType; +}; + +export function Icon({ + lightColor, + darkColor, + name: IconComponent, + color, + size = 24, + strokeWidth = 1.8, + ...rest +}: Props) { + const themedColor = useColor('text', { light: lightColor, dark: darkColor }); + // Use provided color prop if available, otherwise use themed color + const iconColor = color || themedColor; + + return ( + + ); +} diff --git a/frontend/components/ui/image.tsx b/frontend/components/ui/image.tsx new file mode 100644 index 0000000..fd47b8b --- /dev/null +++ b/frontend/components/ui/image.tsx @@ -0,0 +1,174 @@ +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS, CORNERS } from '@/theme/globals'; +import { + Image as ExpoImage, + ImageProps as ExpoImageProps, + ImageSource, +} from 'expo-image'; +import { forwardRef, useState } from 'react'; +import { ActivityIndicator, StyleSheet } from 'react-native'; + +export interface ImageProps extends Omit { + variant?: 'rounded' | 'circle' | 'default'; + source: ImageSource; + style?: ExpoImageProps['style']; + containerStyle?: any; + showLoadingIndicator?: boolean; + showErrorFallback?: boolean; + errorFallbackText?: string; + loadingIndicatorSize?: 'small' | 'large'; + loadingIndicatorColor?: string; + aspectRatio?: number; + width?: number | string; + height?: number | string; +} + +export const Image = forwardRef( + ( + { + variant = 'rounded', + source, + style, + containerStyle, + showLoadingIndicator = true, + showErrorFallback = true, + errorFallbackText = 'Failed to load image', + loadingIndicatorSize = 'small', + loadingIndicatorColor, + aspectRatio, + width, + height, + contentFit = 'cover', + transition = 200, + ...props + }, + ref, + ) => { + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + // Theme colors + const backgroundColor = useColor('muted'); + const textColor = useColor('mutedForeground'); + const primaryColor = useColor('primary'); + + // Get border radius based on variant + const getBorderRadius = () => { + switch (variant) { + case 'circle': + return CORNERS; + case 'rounded': + return BORDER_RADIUS; + case 'default': + return 0; + default: + return BORDER_RADIUS; + } + }; + + const borderRadius = getBorderRadius(); + + // Container dimensions - fill container by default, or use provided dimensions + const containerDimensions = + width || height || aspectRatio + ? { + ...(width ? { width } : {}), + ...(height ? { height } : {}), + ...(aspectRatio ? { aspectRatio } : {}), + } + : { width: '100%', height: '100%' }; + + // Image styles - always fill the container + const imageStyles = [ + { width: '100%', height: '100%', borderRadius }, + style, + ].filter(Boolean) as ExpoImageProps['style']; + + const containerStyles = [ + styles.container, + containerDimensions, + { borderRadius, backgroundColor }, + containerStyle, + ]; + + const handleLoadStart = () => { + setIsLoading(true); + setHasError(false); + }; + + const handleLoadEnd = () => { + setIsLoading(false); + }; + + const handleError = () => { + setIsLoading(false); + setHasError(true); + }; + + return ( + + + + {/* Loading indicator */} + {isLoading && showLoadingIndicator && ( + + + + )} + + {/* Error fallback */} + {hasError && showErrorFallback && ( + + + {errorFallbackText} + + + )} + + ); + }, +); + +const styles = StyleSheet.create({ + container: { + position: 'relative', + overflow: 'hidden', + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + padding: 8, + }, + errorText: { + textAlign: 'center', + fontSize: 12, + }, +}); + +Image.displayName = 'Image'; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..ef60ef1 --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,609 @@ +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; +import { LucideProps } from 'lucide-react-native'; +import React, { forwardRef, ReactElement, useState } from 'react'; +import { + Pressable, + TextInput, + TextInputProps, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +export interface InputProps extends Omit { + label?: string; + error?: string; + icon?: React.ComponentType; + rightComponent?: React.ReactNode | (() => React.ReactNode); + containerStyle?: ViewStyle; + inputStyle?: TextStyle; + labelStyle?: TextStyle; + errorStyle?: TextStyle; + variant?: 'filled' | 'outline'; + disabled?: boolean; + type?: 'input' | 'textarea'; + placeholder?: string; + rows?: number; // Only used when type="textarea" +} + +export const Input = forwardRef( + ( + { + label, + error, + icon, + rightComponent, + containerStyle, + inputStyle, + labelStyle, + errorStyle, + variant = 'filled', + disabled = false, + type = 'input', + rows = 4, + onFocus, + onBlur, + placeholder, + ...props + }, + ref, + ) => { + const [isFocused, setIsFocused] = useState(false); + + // Theme colors + const cardColor = useColor('card'); + const textColor = useColor('text'); + const muted = useColor('mutedForeground'); + const borderColor = useColor('border'); + const primary = useColor('primary'); + const danger = useColor('red'); + + const isTextarea = type === 'textarea'; + + // Calculate height based on type + const getHeight = () => { + if (isTextarea) { + return rows * 20 + 32; // Approximate line height + padding + } + return HEIGHT; + }; + + // Variant styles + const getVariantStyle = (): ViewStyle => { + const baseStyle: ViewStyle = { + borderRadius: isTextarea ? BORDER_RADIUS : CORNERS, + flexDirection: isTextarea ? 'column' : 'row', + alignItems: isTextarea ? 'stretch' : 'center', + minHeight: getHeight(), + paddingHorizontal: 16, + paddingVertical: isTextarea ? 12 : 0, + }; + + switch (variant) { + case 'outline': + return { + ...baseStyle, + borderWidth: 1, + borderColor: error ? danger : isFocused ? primary : borderColor, + backgroundColor: 'transparent', + }; + case 'filled': + default: + return { + ...baseStyle, + borderWidth: 1, + borderColor: error ? danger : cardColor, + backgroundColor: disabled ? muted + '20' : cardColor, + }; + } + }; + + const getInputStyle = (): TextStyle => ({ + flex: 1, + fontSize: FONT_SIZE, + lineHeight: isTextarea ? 20 : undefined, + color: disabled ? muted : error ? danger : textColor, + paddingVertical: 0, // Remove default padding + textAlignVertical: isTextarea ? 'top' : 'center', + }); + + const handleFocus = (e: any) => { + setIsFocused(true); + onFocus?.(e); + }; + + const handleBlur = (e: any) => { + setIsFocused(false); + onBlur?.(e); + }; + + // Render right component - supports both direct components and functions + const renderRightComponent = () => { + if (!rightComponent) return null; + + // If it's a function, call it. Otherwise, render directly + return typeof rightComponent === 'function' + ? rightComponent() + : rightComponent; + }; + + const renderInputContent = () => ( + + {/* Input Container */} + { + if (!disabled && ref && 'current' in ref && ref.current) { + ref.current.focus(); + } + }} + disabled={disabled} + > + {isTextarea ? ( + // Textarea Layout (Column) + <> + {/* Header section with icon, label, and right component */} + {(icon || label || rightComponent) && ( + + {/* Left section - Icon + Label */} + + {icon && ( + + )} + {label && ( + + {label} + + )} + + + {/* Right Component */} + {renderRightComponent()} + + )} + + {/* TextInput section */} + + + ) : ( + // Input Layout (Row) + + {/* Left section - Icon + Label (fixed width to simulate grid column) */} + + {icon && ( + + )} + {label && ( + + {label} + + )} + + + {/* TextInput section - takes remaining space */} + + + + + {/* Right Component */} + {renderRightComponent()} + + )} + + + {/* Error Message */} + {error && ( + + {error} + + )} + + ); + + return renderInputContent(); + }, +); +Input.displayName = 'Input'; + +export interface GroupedInputProps { + children: React.ReactNode; + containerStyle?: ViewStyle; + title?: string; + titleStyle?: TextStyle; +} + +export const GroupedInput = ({ + children, + containerStyle, + title, + titleStyle, +}: GroupedInputProps) => { + const border = useColor('border'); + const background = useColor('card'); + const danger = useColor('red'); + + const childrenArray = React.Children.toArray(children); + + const errors = childrenArray + .filter( + (child): child is ReactElement => + React.isValidElement(child) && !!(child.props as any).error, + ) + .map((child) => child.props.error); + + const renderGroupedContent = () => ( + + {!!title && ( + + {title} + + )} + + + {childrenArray.map((child, index) => ( + + {child} + + ))} + + + {errors.length > 0 && ( + + {errors.map((error, i) => ( + + {error} + + ))} + + )} + + ); + + return renderGroupedContent(); +}; + +export interface GroupedInputItemProps extends Omit { + label?: string; + error?: string; + icon?: React.ComponentType; + rightComponent?: React.ReactNode | (() => React.ReactNode); + inputStyle?: TextStyle; + labelStyle?: TextStyle; + errorStyle?: TextStyle; + disabled?: boolean; + type?: 'input' | 'textarea'; + rows?: number; // Only used when type="textarea" +} + +export const GroupedInputItem = forwardRef( + ( + { + label, + error, + icon, + rightComponent, + inputStyle, + labelStyle, + errorStyle, + disabled, + type = 'input', + rows = 3, + onFocus, + onBlur, + placeholder, + ...props + }, + ref, + ) => { + const [isFocused, setIsFocused] = useState(false); + + const text = useColor('text'); + const muted = useColor('mutedForeground'); + const primary = useColor('primary'); + const danger = useColor('red'); + + const isTextarea = type === 'textarea'; + + const handleFocus = (e: any) => { + setIsFocused(true); + onFocus?.(e); + }; + + const handleBlur = (e: any) => { + setIsFocused(false); + onBlur?.(e); + }; + + const renderRightComponent = () => { + if (!rightComponent) return null; + return typeof rightComponent === 'function' + ? rightComponent() + : rightComponent; + }; + + const renderItemContent = () => ( + ref && 'current' in ref && ref.current?.focus()} + disabled={disabled} + style={{ opacity: disabled ? 0.6 : 1 }} + > + + {isTextarea ? ( + // Textarea Layout (Column) + <> + {/* Header section with icon, label, and right component */} + {(icon || label || rightComponent) && ( + + {/* Icon & Label */} + + {icon && ( + + )} + {label && ( + + {label} + + )} + + + {/* Right Component */} + {renderRightComponent()} + + )} + + {/* Textarea Input */} + + + ) : ( + // Input Layout (Row) + + {/* Icon & Label */} + + {icon && ( + + )} + {label && ( + + {label} + + )} + + + {/* Input */} + + + + + {/* Right Component */} + {renderRightComponent()} + + )} + + + ); + + return renderItemContent(); + }, +); +GroupedInputItem.displayName = 'GroupedInputItem'; diff --git a/frontend/components/ui/media-picker.tsx b/frontend/components/ui/media-picker.tsx new file mode 100644 index 0000000..a24afb8 --- /dev/null +++ b/frontend/components/ui/media-picker.tsx @@ -0,0 +1,551 @@ +import { Button, ButtonSize, ButtonVariant } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { useColor } from '@/hooks/useColor'; +import { CORNERS, FONT_SIZE } from '@/theme/globals'; +import { Image as ExpoImage } from 'expo-image'; +import * as ImagePicker from 'expo-image-picker'; +import * as MediaLibrary from 'expo-media-library'; +import { LucideProps, Video, X } from 'lucide-react-native'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; +import { + Dimensions, + FlatList, + Modal, + Pressable, + View as RNView, + StyleSheet, + TouchableOpacity, + ViewStyle, +} from 'react-native'; + +export type MediaType = 'image' | 'video' | 'all'; +export type MediaQuality = 'low' | 'medium' | 'high'; + +export interface MediaAsset { + id: string; + uri: string; + type: 'image' | 'video'; + width?: number; + height?: number; + duration?: number; + filename?: string; + fileSize?: number; +} + +export interface MediaPickerProps { + children?: React.ReactNode; + style?: ViewStyle; + size?: ButtonSize; + variant?: ButtonVariant; + icon?: React.ComponentType; + disabled?: boolean; + mediaType?: MediaType; + multiple?: boolean; + maxSelection?: number; + quality?: MediaQuality; + buttonText?: string; + placeholder?: string; + gallery?: boolean; + showPreview?: boolean; + previewSize?: number; + selectedAssets?: MediaAsset[]; + onSelectionChange?: (assets: MediaAsset[]) => void; + onError?: (error: string) => void; +} + +const { width: screenWidth } = Dimensions.get('window'); + +// Helper function to compare arrays of MediaAssets +const arraysEqual = (a: MediaAsset[], b: MediaAsset[]): boolean => { + if (a.length !== b.length) return false; + return a.every((item, index) => { + const bItem = b[index]; + return ( + item.id === bItem.id && item.uri === bItem.uri && item.type === bItem.type + ); + }); +}; + +export const MediaPicker = forwardRef( + ( + { + children, + mediaType = 'all', + multiple = false, + gallery = false, + maxSelection = 10, + quality = 'high', + onSelectionChange, + onError, + buttonText, + showPreview = true, + previewSize = 80, + style, + variant, + size, + icon, + disabled = false, + selectedAssets = [], + }, + ref, + ) => { + const [assets, setAssets] = useState(selectedAssets); + const [isGalleryVisible, setIsGalleryVisible] = useState(false); + const [galleryAssets, setGalleryAssets] = useState( + [], + ); + const [hasPermission, setHasPermission] = useState(null); + + // Use ref to track previous selectedAssets to avoid unnecessary updates + const prevSelectedAssetsRef = useRef(selectedAssets); + + // Theme colors + const cardColor = useColor('card'); + const borderColor = useColor('border'); + const textColor = useColor('text'); + const mutedForeground = useColor('mutedForeground'); + const primaryColor = useColor('primary'); + const secondary = useColor('secondary'); + + // Request permissions on mount + useEffect(() => { + requestPermissions(); + }, []); + + // Update internal state when selectedAssets prop changes (with proper comparison) + useEffect(() => { + // Only update if the arrays are actually different + if (!arraysEqual(prevSelectedAssetsRef.current, selectedAssets)) { + setAssets(selectedAssets); + prevSelectedAssetsRef.current = selectedAssets; + } + }, [selectedAssets]); + + const requestPermissions = async () => { + try { + const { status } = await MediaLibrary.requestPermissionsAsync(); + setHasPermission(status === 'granted'); + + if (status !== 'granted') { + onError?.( + 'Media library permission is required to access photos and videos', + ); + } + } catch (error) { + onError?.('Failed to request permissions'); + setHasPermission(false); + } + }; + + const loadGalleryAssets = async () => { + if (!hasPermission) return; + + try { + const mediaTypeFilter = + mediaType === 'image' + ? [MediaLibrary.MediaType.photo] + : mediaType === 'video' + ? [MediaLibrary.MediaType.video] + : [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; + + const { assets: galleryAssets } = await MediaLibrary.getAssetsAsync({ + first: 100, + mediaType: mediaTypeFilter, + sortBy: MediaLibrary.SortBy.creationTime, + }); + + setGalleryAssets(galleryAssets); + } catch (error) { + onError?.('Failed to load gallery assets'); + } + }; + + const pickFromGallery = async () => { + if (!hasPermission) { + await requestPermissions(); + return; + } + + if (gallery) { + await loadGalleryAssets(); + setIsGalleryVisible(true); + return; + } + + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: + mediaType === 'image' + ? ImagePicker.MediaTypeOptions.Images + : mediaType === 'video' + ? ImagePicker.MediaTypeOptions.Videos + : ImagePicker.MediaTypeOptions.All, + allowsMultipleSelection: multiple, + quality: quality === 'high' ? 1 : quality === 'medium' ? 0.7 : 0.3, + selectionLimit: multiple ? maxSelection : 1, + }); + + if (!result.canceled && result.assets) { + const newAssets = result.assets.map((asset, index) => ({ + id: `gallery_${Date.now()}_${index}`, + uri: asset.uri, + type: + asset.type === 'video' ? ('video' as const) : ('image' as const), + width: asset.width, + height: asset.height, + duration: asset.duration || undefined, + filename: asset.fileName || undefined, + fileSize: asset.fileSize, + })); + + handleAssetSelection(newAssets); + } + } catch (error) { + onError?.('Failed to pick media from gallery'); + } + }; + + const handleAssetSelection = (newAssets: MediaAsset[]) => { + let updatedAssets: MediaAsset[]; + + if (multiple) { + updatedAssets = [...assets, ...newAssets].slice(0, maxSelection); + } else { + updatedAssets = newAssets; + } + + setAssets(updatedAssets); + prevSelectedAssetsRef.current = updatedAssets; // Update ref to prevent loop + onSelectionChange?.(updatedAssets); + }; + + const handleGalleryAssetSelect = async ( + galleryAsset: MediaLibrary.Asset, + ) => { + try { + const assetInfo = await MediaLibrary.getAssetInfoAsync(galleryAsset); + + const newAsset: MediaAsset = { + id: galleryAsset.id, + uri: assetInfo.localUri || galleryAsset.uri, + type: + galleryAsset.mediaType === MediaLibrary.MediaType.video + ? 'video' + : 'image', + width: galleryAsset.width, + height: galleryAsset.height, + duration: galleryAsset.duration || undefined, + filename: galleryAsset.filename, + }; + + if (multiple) { + const isAlreadySelected = assets.some( + (asset) => asset.id === newAsset.id, + ); + if (isAlreadySelected) { + const filteredAssets = assets.filter( + (asset) => asset.id !== newAsset.id, + ); + setAssets(filteredAssets); + prevSelectedAssetsRef.current = filteredAssets; // Update ref + onSelectionChange?.(filteredAssets); + } else if (assets.length < maxSelection) { + const updatedAssets = [...assets, newAsset]; + setAssets(updatedAssets); + prevSelectedAssetsRef.current = updatedAssets; // Update ref + onSelectionChange?.(updatedAssets); + } + } else { + const newAssets = [newAsset]; + setAssets(newAssets); + prevSelectedAssetsRef.current = newAssets; // Update ref + onSelectionChange?.(newAssets); + setIsGalleryVisible(false); + } + } catch (error) { + onError?.('Failed to select asset'); + } + }; + + const removeAsset = (assetId: string) => { + const filteredAssets = assets.filter((asset) => asset.id !== assetId); + setAssets(filteredAssets); + prevSelectedAssetsRef.current = filteredAssets; // Update ref + onSelectionChange?.(filteredAssets); + }; + + const renderPreviewItem = ({ item }: { item: MediaAsset }) => ( + + + {item.type === 'video' && ( + + + )} + removeAsset(item.id)} + > + + + + ); + + const renderGalleryItem = ({ item }: { item: MediaLibrary.Asset }) => { + const isSelected = assets.some((asset) => asset.id === item.id); + const itemWidth = screenWidth / 3 - 4; + + return ( + handleGalleryAssetSelect(item)} + > + + {item.mediaType === MediaLibrary.MediaType.video && ( + + + )} + {multiple && isSelected && ( + + + {assets.findIndex((asset) => asset.id === item.id) + 1} + + + )} + + ); + }; + + return ( + + {children ? ( + children + ) : ( + + )} + + {showPreview && assets.length > 0 && ( + item.id} + horizontal + showsHorizontalScrollIndicator={false} + style={styles.previewContainer} + contentContainerStyle={styles.previewContent} + /> + )} + + {gallery && ( + + + + + {buttonText || + `Select ${ + mediaType === 'all' + ? 'Media' + : mediaType === 'image' + ? 'Images' + : 'Videos' + }`} + + + {multiple && ( + + {assets.length}/{maxSelection} + + )} + + + + + + item.id} + numColumns={3} + contentContainerStyle={styles.galleryContent} + /> + + + )} + + ); + }, +); + +const styles = StyleSheet.create({ + compactButton: { + width: 60, + height: 60, + borderRadius: CORNERS, + borderWidth: 1, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + }, + + disabled: { + opacity: 0.5, + }, + + previewContainer: { + marginTop: 12, + }, + + previewContent: { + paddingHorizontal: 4, + }, + + previewItem: { + marginHorizontal: 4, + borderRadius: 8, + borderWidth: 1, + overflow: 'hidden', + position: 'relative', + }, + + previewImage: { + borderRadius: 8, + }, + + videoIndicator: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 12, + padding: 4, + }, + + removeButton: { + position: 'absolute', + top: 6, + right: 6, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + + modalContainer: { + flex: 1, + }, + + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + + modalActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + + selectionCount: { + fontSize: FONT_SIZE, + fontWeight: '500', + }, + + closeButton: { + padding: 4, + }, + + galleryContent: { + padding: 2, + }, + + galleryItem: { + margin: 1, + borderRadius: 4, + overflow: 'hidden', + position: 'relative', + }, + + galleryImage: { + width: '100%', + height: '100%', + }, + + selectedIndicator: { + position: 'absolute', + top: 8, + right: 8, + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +MediaPicker.displayName = 'MediaPicker'; diff --git a/frontend/components/ui/onboarding.tsx b/frontend/components/ui/onboarding.tsx new file mode 100644 index 0000000..b6eea01 --- /dev/null +++ b/frontend/components/ui/onboarding.tsx @@ -0,0 +1,357 @@ +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useColor } from '../../hooks/useColor'; +import React, { useRef, useState } from 'react'; +import { + Dimensions, + ScrollView, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +const { width: screenWidth } = Dimensions.get('window'); + +export interface OnboardingStep { + id: string; + title: string; + description: string; + image?: React.ReactNode; + icon?: React.ReactNode; + backgroundColor?: string; +} + +export interface OnboardingProps { + steps: OnboardingStep[]; + onComplete: () => void; + onSkip?: () => void; + showSkip?: boolean; + showProgress?: boolean; + swipeEnabled?: boolean; + primaryButtonText?: string; + skipButtonText?: string; + nextButtonText?: string; + backButtonText?: string; + style?: ViewStyle; + children?: React.ReactNode; +} + +// Enhanced Onboarding Step Component for complex layouts +interface OnboardingStepContentProps { + step: OnboardingStep; + isActive: boolean; + children?: React.ReactNode; +} + +export function Onboarding({ + steps, + onComplete, + onSkip, + showSkip = true, + showProgress = true, + swipeEnabled = true, + primaryButtonText = 'Get Started', + skipButtonText = 'Skip', + nextButtonText = 'Next', + backButtonText = 'Back', + style, + children, +}: OnboardingProps) { + const [currentStep, setCurrentStep] = useState(0); + const scrollViewRef = useRef(null); + const translateX = useSharedValue(0); + + const backgroundColor = useColor('background'); + const primaryColor = useColor('primary'); + const mutedForeground = useColor('mutedForeground'); + + const isLastStep = currentStep === steps.length - 1; + const isFirstStep = currentStep === 0; + + const handleNext = () => { + if (isLastStep) { + onComplete(); + } else { + const nextStep = currentStep + 1; + setCurrentStep(nextStep); + scrollViewRef.current?.scrollTo({ + x: nextStep * screenWidth, + animated: true, + }); + } + }; + + const handleBack = () => { + if (!isFirstStep) { + const prevStep = currentStep - 1; + setCurrentStep(prevStep); + scrollViewRef.current?.scrollTo({ + x: prevStep * screenWidth, + animated: true, + }); + } + }; + + const handleSkip = () => { + if (onSkip) { + onSkip(); + } else { + onComplete(); + } + }; + + // Modern gesture handling with Gesture API + const panGesture = Gesture.Pan() + .enabled(swipeEnabled) + .onUpdate((event) => { + translateX.value = event.translationX; + }) + .onEnd((event) => { + const { translationX, velocityX } = event; + const shouldSwipe = + Math.abs(translationX) > screenWidth * 0.3 || Math.abs(velocityX) > 500; + + if (shouldSwipe) { + if (translationX > 0 && !isFirstStep) { + // Swipe right - go back + runOnJS(handleBack)(); + } else if (translationX < 0 && !isLastStep) { + // Swipe left - go next + runOnJS(handleNext)(); + } + } + + translateX.value = withSpring(0); + }); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const renderProgressDots = () => { + if (!showProgress) return null; + + return ( + + {steps.map((_, index) => ( + + ))} + + ); + }; + + const renderStep = (step: OnboardingStep, index: number) => { + const isActive = index === currentStep; + + return ( + + + {step.image && ( + {step.image} + )} + + {step.icon && !step.image && ( + {step.icon} + )} + + + + {step.title} + + + {step.description} + + + + {children && {children}} + + + ); + }; + + return ( + + + + { + const newStep = Math.round( + event.nativeEvent.contentOffset.x / screenWidth, + ); + setCurrentStep(newStep); + }} + > + {steps.map((step, index) => renderStep(step, index))} + + + + + {/* Progress Dots */} + {renderProgressDots()} + + {/* Skip Button */} + {showSkip && !isLastStep && ( + + + + )} + + {/* Navigation Buttons */} + + {/* {!isFirstStep && ( + + )} */} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + stepContainer: { + width: screenWidth, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + contentContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + maxWidth: 400, + }, + imageContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 40, + minHeight: 200, + }, + textContainer: { + alignItems: 'center', + paddingHorizontal: 20, + marginBottom: 40, + }, + title: { + textAlign: 'center', + marginBottom: 16, + paddingHorizontal: 20, + }, + description: { + textAlign: 'center', + lineHeight: 24, + paddingHorizontal: 20, + }, + customContent: { + alignItems: 'center', + paddingHorizontal: 20, + marginTop: 20, + }, + progressContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 20, + }, + progressDot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + }, + skipContainer: { + position: 'absolute', + top: 60, + right: 10, + zIndex: 1, + }, + buttonContainer: { + width: '100%', + height: 90, + flexDirection: 'row', + paddingHorizontal: 24, + paddingBottom: 40, + gap: 12, + }, + fullWidthButton: { + flex: 1, + }, +}); + +// Onboarding Hook for managing state +export function useOnboarding() { + const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(false); + const [currentOnboardingStep, setCurrentOnboardingStep] = useState(0); + + const completeOnboarding = async () => { + try { + // In a real app, you'd save this to AsyncStorage or similar + setHasCompletedOnboarding(true); + console.log('Onboarding completed and saved'); + } catch (error) { + console.error('Failed to save onboarding completion:', error); + } + }; + + const resetOnboarding = () => { + setHasCompletedOnboarding(false); + setCurrentOnboardingStep(0); + }; + + const skipOnboarding = async () => { + await completeOnboarding(); + }; + + return { + hasCompletedOnboarding, + currentOnboardingStep, + setCurrentOnboardingStep, + completeOnboarding, + resetOnboarding, + skipOnboarding, + }; +} diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 0000000..0aa39a0 --- /dev/null +++ b/frontend/components/ui/popover.tsx @@ -0,0 +1,450 @@ +import { Button } from '@/components/ui/button'; +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS } from '@/theme/globals'; +import React, { + createContext, + ReactNode, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + Dimensions, + Modal, + Pressable, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; + +// Context for sharing state between popover components +interface PopoverContextType { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + triggerLayout: { x: number; y: number; width: number; height: number }; + setTriggerLayout: (layout: any) => void; +} + +const PopoverContext = createContext(undefined); + +const usePopover = () => { + const context = useContext(PopoverContext); + if (!context) { + throw new Error('Popover components must be used within a Popover'); + } + return context; +}; + +// Main Popover wrapper +interface PopoverProps { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function Popover({ + children, + open = false, + onOpenChange, +}: PopoverProps) { + const [isOpen, setIsOpenState] = useState(open); + const [triggerLayout, setTriggerLayout] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + // Sync with external open state + useEffect(() => { + setIsOpenState(open); + }, [open]); + + const setIsOpen = (newOpen: boolean) => { + setIsOpenState(newOpen); + onOpenChange?.(newOpen); + }; + + return ( + + {children} + + ); +} + +// Popover Trigger +interface PopoverTriggerProps { + children: ReactNode; + asChild?: boolean; + style?: ViewStyle; +} + +export function PopoverTrigger({ + children, + asChild = false, + style, +}: PopoverTriggerProps) { + const { setIsOpen, setTriggerLayout, isOpen } = usePopover(); + const triggerRef = useRef>(null); + + const measureTrigger = () => { + if (triggerRef.current) { + triggerRef.current.measure( + ( + x: number, + y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => { + setTriggerLayout({ x: pageX, y: pageY, width, height }); + }, + ); + } + }; + + const handlePress = () => { + measureTrigger(); + setIsOpen(!isOpen); + }; + + if (asChild && React.isValidElement(children)) { + // Clone the child and add onPress handler + return React.cloneElement(children, { + ref: triggerRef, + onPress: handlePress, + style: [(children.props as any).style, style], + } as any); + } + + return ( + + ); +} + +// Popover Content +interface PopoverContentProps { + children: ReactNode; + align?: 'start' | 'center' | 'end'; + side?: 'top' | 'right' | 'bottom' | 'left'; + sideOffset?: number; + alignOffset?: number; + style?: ViewStyle; + maxWidth?: number; + maxHeight?: number; +} + +export function PopoverContent({ + children, + align = 'center', + side = 'bottom', + sideOffset = 8, + alignOffset = 0, + style, + maxWidth = 300, + maxHeight = 400, +}: PopoverContentProps) { + const { isOpen, setIsOpen, triggerLayout } = usePopover(); + const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); + const popoverColor = useColor('card'); + const borderColor = useColor('border'); + + const handleClose = () => { + setIsOpen(false); + }; + + // Calculate position based on side and align props + const getPosition = () => { + const screenDimensions = Dimensions.get('window'); + const { x, y, width, height } = triggerLayout; + + // Use actual content size if available, otherwise use maxWidth/maxHeight + const contentWidth = contentSize.width || maxWidth; + const contentHeight = Math.min( + contentSize.height || maxHeight, + screenDimensions.height * 0.8, + ); + + let top = 0; + let left = 0; + let actualSide = side; + + // Initial position calculation based on preferred side + switch (side) { + case 'top': + top = y - contentHeight - sideOffset; + break; + case 'bottom': + top = y + height + sideOffset; + break; + case 'left': + left = x - contentWidth - sideOffset; + break; + case 'right': + left = x + width + sideOffset; + break; + } + + // Calculate alignment for vertical sides (top/bottom) + if (side === 'top' || side === 'bottom') { + switch (align) { + case 'start': + left = x + alignOffset; + break; + case 'center': + left = x + width / 2 - contentWidth / 2 + alignOffset; + break; + case 'end': + left = x + width - contentWidth + alignOffset; + break; + } + } + // Calculate alignment for horizontal sides (left/right) + else { + switch (align) { + case 'start': + top = y + alignOffset; + break; + case 'center': + top = y + height / 2 - contentHeight / 2 + alignOffset; + break; + case 'end': + top = y + height - contentHeight + alignOffset; + break; + } + } + + // Screen boundary adjustments with side flipping + const padding = 16; + + // Check if we need to flip sides due to space constraints + if (side === 'top' && top < padding) { + // Not enough space on top, try bottom + const bottomSpace = screenDimensions.height - (y + height + sideOffset); + if (bottomSpace >= contentHeight) { + actualSide = 'bottom'; + top = y + height + sideOffset; + } else { + // Keep top but adjust position + top = padding; + } + } else if ( + side === 'bottom' && + top + contentHeight > screenDimensions.height - padding + ) { + // Not enough space on bottom, try top + const topSpace = y - sideOffset; + if (topSpace >= contentHeight) { + actualSide = 'top'; + top = y - contentHeight - sideOffset; + } else { + // Keep bottom but adjust position + top = screenDimensions.height - contentHeight - padding; + } + } else if (side === 'left' && left < padding) { + // Not enough space on left, try right + const rightSpace = screenDimensions.width - (x + width + sideOffset); + if (rightSpace >= contentWidth) { + actualSide = 'right'; + left = x + width + sideOffset; + } else { + // Keep left but adjust position + left = padding; + } + } else if ( + side === 'right' && + left + contentWidth > screenDimensions.width - padding + ) { + // Not enough space on right, try left + const leftSpace = x - sideOffset; + if (leftSpace >= contentWidth) { + actualSide = 'left'; + left = x - contentWidth - sideOffset; + } else { + // Keep right but adjust position + left = screenDimensions.width - contentWidth - padding; + } + } + + // Final boundary adjustments (without side flipping) + if (left < padding) { + left = padding; + } else if (left + contentWidth > screenDimensions.width - padding) { + left = screenDimensions.width - contentWidth - padding; + } + + if (top < padding) { + top = padding; + } else if (top + contentHeight > screenDimensions.height - padding) { + top = screenDimensions.height - contentHeight - padding; + } + + return { + top: Math.max(padding, top), + left: Math.max(padding, left), + maxWidth, + maxHeight: Math.min(maxHeight, screenDimensions.height - 2 * padding), + actualSide, + }; + }; + + const position = getPosition(); + + const handleContentLayout = (event: any) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }; + + return ( + + + true} + > + {children} + + + + ); +} + +// Popover Header +interface PopoverHeaderProps { + children: ReactNode; + style?: ViewStyle; +} + +export function PopoverHeader({ children, style }: PopoverHeaderProps) { + const borderColor = useColor('border'); + + return ( + + {children} + + ); +} + +// Popover Body +interface PopoverBodyProps { + children: ReactNode; + style?: ViewStyle; +} + +export function PopoverBody({ children, style }: PopoverBodyProps) { + return {children}; +} + +// Popover Footer +interface PopoverFooterProps { + children: ReactNode; + style?: ViewStyle; +} + +export function PopoverFooter({ children, style }: PopoverFooterProps) { + const borderColor = useColor('border'); + + return ( + + {children} + + ); +} + +// Popover Close (utility component) +interface PopoverCloseProps { + children: ReactNode; + asChild?: boolean; + style?: ViewStyle; +} + +export function PopoverClose({ + children, + asChild = false, + style, +}: PopoverCloseProps) { + const { setIsOpen } = usePopover(); + + const handlePress = () => { + setIsOpen(false); + }; + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + onPress: handlePress, + style: [(children.props as any).style, style], + } as any); + } + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + content: { + position: 'absolute', + borderRadius: BORDER_RADIUS, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.25, + shadowRadius: 6, + elevation: 8, + minWidth: 200, // Ensure minimum width + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + }, + body: { + padding: 16, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 12, + borderTopWidth: 1, + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 8, + }, +}); diff --git a/frontend/components/ui/scroll-view.tsx b/frontend/components/ui/scroll-view.tsx new file mode 100644 index 0000000..a483d43 --- /dev/null +++ b/frontend/components/ui/scroll-view.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from 'react'; +import { ScrollView as RNScrollView, ScrollViewProps } from 'react-native'; + +export const ScrollView = forwardRef( + ({ style, ...otherProps }, ref) => { + return ( + + ); + }, +); +ScrollView.displayName = 'ScrollView'; diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx new file mode 100644 index 0000000..4c472a2 --- /dev/null +++ b/frontend/components/ui/skeleton.tsx @@ -0,0 +1,64 @@ +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS, CORNERS } from '@/theme/globals'; +import React, { useEffect } from 'react'; +import { ViewStyle } from 'react-native'; +import Animated, { + Easing, + useSharedValue, + useAnimatedStyle, + withTiming, + withRepeat, +} from 'react-native-reanimated'; + +interface SkeletonProps { + width?: number | string; + height?: number; + style?: ViewStyle; + variant?: 'default' | 'rounded'; +} + +export function Skeleton({ + width = '100%', + height = 100, + style, + variant = 'default', +}: SkeletonProps) { + const mutedForeground = useColor('muted'); + // Start the opacity at its lowest point + const opacity = useSharedValue(0.5); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + useEffect(() => { + // We only define the animation going from 0.5 -> 1. + // The `withRepeat` function will handle reversing it automatically. + opacity.value = withRepeat( + // Animate to an opacity of 1 + withTiming(1, { + duration: 1000, + easing: Easing.inOut(Easing.quad), + }), + -1, // Loop infinitely + true, // Set to true to automatically reverse the animation (yoyo effect) + ); + }, [opacity]); // Use an empty dependency array as the shared value object is stable + + return ( + + ); +} diff --git a/frontend/components/ui/spinner.tsx b/frontend/components/ui/spinner.tsx new file mode 100644 index 0000000..c3e8a14 --- /dev/null +++ b/frontend/components/ui/spinner.tsx @@ -0,0 +1,464 @@ +import { Text } from '@/components/ui/text'; +import { useColor } from '@/hooks/useColor'; +import { BORDER_RADIUS, CORNERS, FONT_SIZE } from '@/theme/globals'; +import { Loader2 } from 'lucide-react-native'; +import React, { useEffect, useMemo } from 'react'; +import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native'; +import Animated, { + Easing, + SharedValue, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +// Types +type SpinnerSize = 'default' | 'sm' | 'lg' | 'icon'; +export type SpinnerVariant = 'default' | 'circle' | 'dots' | 'pulse' | 'bars'; + +interface SpinnerProps { + size?: SpinnerSize; + variant?: SpinnerVariant; + label?: string; + showLabel?: boolean; + style?: ViewStyle; + color?: string; + thickness?: number; // Note: thickness is not used in the original component logic + speed?: 'slow' | 'normal' | 'fast'; +} + +interface LoadingOverlayProps extends SpinnerProps { + visible: boolean; + backdrop?: boolean; + backdropColor?: string; + backdropOpacity?: number; + onRequestClose?: () => void; +} + +interface SpinnerConfig { + size: number; + iconSize: number; + fontSize: number; + gap: number; + thickness: number; +} + +// Configuration +const sizeConfig: Record = { + sm: { size: 16, iconSize: 16, fontSize: 12, gap: 6, thickness: 2 }, + default: { + size: 24, + iconSize: 24, + fontSize: FONT_SIZE, + gap: 8, + thickness: 2, + }, + lg: { size: 32, iconSize: 32, fontSize: 16, gap: 10, thickness: 3 }, + icon: { size: 24, iconSize: 24, fontSize: FONT_SIZE, gap: 8, thickness: 2 }, +}; + +const speedConfig = { + slow: 1500, + normal: 1000, + fast: 500, +}; + +// --- Helper Animated Components for Dots and Bars --- + +interface AnimatedShapeProps { + anim: SharedValue; + color: string; + size: number; + style: ViewStyle; +} + +const AnimatedDot = React.memo( + ({ anim, color, size, style }: AnimatedShapeProps) => { + const animatedStyle = useAnimatedStyle(() => ({ + opacity: anim.value, + })); + return ( + + ); + }, +); +AnimatedDot.displayName = 'AnimatedDot'; + +const AnimatedBar = React.memo( + ({ anim, color, size, style }: AnimatedShapeProps) => { + const animatedStyle = useAnimatedStyle(() => ({ + opacity: anim.value, + })); + return ( + + ); + }, +); +AnimatedBar.displayName = 'AnimatedBar'; + +// Main Spinner Component +export function Spinner({ + size = 'default', + variant = 'default', + label, + showLabel = false, + style, + color, + speed = 'normal', +}: SpinnerProps) { + // Reanimated shared values + const rotate = useSharedValue(0); + const pulse = useSharedValue(1); + + // --- FIX: Call hooks at the top level --- + // 1. Call useSharedValue at the top level for each dot/bar + const dotAnim1 = useSharedValue(0.3); + const dotAnim2 = useSharedValue(0.3); + const dotAnim3 = useSharedValue(0.3); + + const barAnim1 = useSharedValue(0.3); + const barAnim2 = useSharedValue(0.3); + const barAnim3 = useSharedValue(0.3); + const barAnim4 = useSharedValue(0.3); + + // 2. Use useMemo to create a stable array reference from the values + const dotsAnims = useMemo( + () => [dotAnim1, dotAnim2, dotAnim3], + [dotAnim1, dotAnim2, dotAnim3], + ); + const barsAnims = useMemo( + () => [barAnim1, barAnim2, barAnim3, barAnim4], + [barAnim1, barAnim2, barAnim3, barAnim4], + ); + // --- END FIX --- + + // Theme colors + const primaryColor = useColor('text'); + const textColor = useColor('text'); + + const config = sizeConfig[size]; + const spinnerColor = color || primaryColor; + const animationDuration = speedConfig[speed]; + + // Rotation animation + useEffect(() => { + if (variant === 'circle') { + rotate.value = withRepeat( + withTiming(360, { duration: animationDuration, easing: Easing.linear }), + -1, + ); + } else { + rotate.value = 0; // Reset + } + }, [rotate, variant, animationDuration]); + + // Pulse animation + useEffect(() => { + if (variant === 'pulse') { + pulse.value = withRepeat( + withSequence( + withTiming(1.3, { duration: animationDuration / 2 }), + withTiming(1, { duration: animationDuration / 2 }), + ), + -1, + true, + ); + } else { + pulse.value = 1; // Reset + } + }, [pulse, variant, animationDuration]); + + // Dots animation + useEffect(() => { + if (variant === 'dots') { + dotsAnims.forEach((anim, index) => { + anim.value = withRepeat( + withSequence( + withDelay( + index * (animationDuration / 6), + withTiming(1, { duration: animationDuration / 3 }), + ), + withTiming(0.3, { duration: animationDuration / 3 }), + ), + -1, + ); + }); + } else { + dotsAnims.forEach((anim) => (anim.value = 0.3)); // Reset + } + }, [dotsAnims, variant, animationDuration]); + + // Bars animation + useEffect(() => { + if (variant === 'bars') { + barsAnims.forEach((anim, index) => { + anim.value = withRepeat( + withSequence( + withDelay( + index * (animationDuration / 8), + withTiming(1, { duration: animationDuration / 4 }), + ), + withTiming(0.3, { duration: animationDuration / 4 }), + ), + -1, + ); + }); + } else { + barsAnims.forEach((anim) => (anim.value = 0.3)); // Reset + } + }, [barsAnims, variant, animationDuration]); + + // Animated styles + const animatedCircleStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotate.value}deg` }], + })); + + const animatedPulseStyle = useAnimatedStyle(() => ({ + transform: [{ scale: pulse.value }], + })); + + const renderSpinner = () => { + switch (variant) { + case 'default': + return ( + + ); + + case 'circle': + return ( + + + + ); + + case 'pulse': + return ( + + ); + + case 'dots': + return ( + + {dotsAnims.map((anim, index) => ( + + ))} + + ); + + case 'bars': + return ( + + {barsAnims.map((anim, index) => ( + + ))} + + ); + + default: + return null; + } + }; + + const containerStyle: ViewStyle = { + alignItems: 'center', + justifyContent: 'center', + gap: config.gap, + }; + + return ( + + {renderSpinner()} + {(showLabel || label) && ( + + {label || 'Loading...'} + + )} + + ); +} + +// Loading Overlay Component +export function LoadingOverlay({ + visible, + backdrop = true, + backdropColor, + backdropOpacity = 0.5, + ...spinnerProps +}: LoadingOverlayProps) { + const opacity = useSharedValue(0); + const backgroundColor = useColor('background'); + const cardColor = useColor('card'); + + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: 200, + }); + }, [visible, opacity]); + + const animatedOverlayStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + // Conditionally render to avoid interaction issues + display: opacity.value === 0 ? 'none' : 'flex', + })); + + const defaultBackdropColor = + backdropColor || + `${backgroundColor}${Math.round(backdropOpacity * 255) + .toString(16) + .padStart(2, '0')}`; + + return ( + + + + + + ); +} + +// Inline Loader Component (for buttons, etc.) +export function InlineLoader({ + size = 'sm', + variant = 'default', + color, +}: Omit) { + return ( + + ); +} + +// Button Spinner Component - optimized for button usage +export function ButtonSpinner({ + size = 'sm', + variant = 'default', + color, +}: Omit) { + const primaryForegroundColor = useColor('primaryForeground'); + + return ( + + ); +} + +const styles = StyleSheet.create({ + spinner: { + alignSelf: 'center', + }, + customSpinner: { + alignItems: 'center', + justifyContent: 'center', + }, + pulseSpinner: { + borderRadius: 999, + }, + dotsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + dot: { + borderRadius: 999, + }, + barsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + bar: { + borderRadius: CORNERS, + }, + label: { + textAlign: 'center', + fontWeight: '500', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + }, + overlayContent: { + padding: 60, + borderRadius: BORDER_RADIUS, + }, + inlineLoader: { + minHeight: 0, + minWidth: 0, + }, + buttonSpinner: { + minHeight: 0, + minWidth: 0, + }, +}); diff --git a/frontend/components/ui/switch.tsx b/frontend/components/ui/switch.tsx new file mode 100644 index 0000000..a9e7fdb --- /dev/null +++ b/frontend/components/ui/switch.tsx @@ -0,0 +1,78 @@ +import { useColor } from '@/hooks/useColor'; +import React from 'react'; + +import { Text } from '@/components/ui/text'; +import { View } from '@/components/ui/view'; +import { + Switch as RNSwitch, + SwitchProps as RNSwitchProps, + TextStyle, +} from 'react-native'; + +interface SwitchProps extends RNSwitchProps { + label?: string; + error?: string; + labelStyle?: TextStyle; +} + +export function Switch({ label, error, labelStyle, ...props }: SwitchProps) { + const mutedForeground = useColor('muted'); + const primary = useColor('primary'); + const danger = useColor('red'); + + return ( + + + {label && ( + + {label} + + )} + + + + + {error && ( + + {error} + + )} + + ); +} diff --git a/frontend/components/ui/text.tsx b/frontend/components/ui/text.tsx new file mode 100644 index 0000000..1c86a56 --- /dev/null +++ b/frontend/components/ui/text.tsx @@ -0,0 +1,88 @@ +import { useColor } from '@/hooks/useColor'; +import { FONT_SIZE } from '@/theme/globals'; +import React, { forwardRef } from 'react'; +import { + Text as RNText, + TextProps as RNTextProps, + TextStyle, +} from 'react-native'; + +type TextVariant = + | 'body' + | 'title' + | 'subtitle' + | 'caption' + | 'heading' + | 'link'; + +interface TextProps extends RNTextProps { + variant?: TextVariant; + lightColor?: string; + darkColor?: string; + children: React.ReactNode; +} + +export const Text = forwardRef( + ( + { variant = 'body', lightColor, darkColor, style, children, ...props }, + ref, + ) => { + const mutedForeground = useColor('mutedForeground'); + const textColor = useColor('text', { light: lightColor, dark: darkColor }); + // const muted = useColor('mutedForeground'); + + const getTextStyle = (): TextStyle => { + const baseStyle: TextStyle = { + color: textColor, + }; + + switch (variant) { + case 'heading': + return { + ...baseStyle, + fontSize: 28, + fontWeight: '800', + }; + case 'title': + return { + ...baseStyle, + fontSize: 24, + fontWeight: '700', + }; + case 'subtitle': + return { + ...baseStyle, + fontSize: 19, + fontWeight: '600', + }; + case 'caption': + return { + ...baseStyle, + fontSize: FONT_SIZE, + fontWeight: '400', + color: mutedForeground, + }; + case 'link': + return { + ...baseStyle, + fontSize: FONT_SIZE, + fontWeight: '500', + textDecorationLine: 'underline', + }; + default: // 'body' + return { + ...baseStyle, + fontSize: FONT_SIZE, + fontWeight: '400', + }; + } + }; + + return ( + + {children} + + ); + }, +); +Text.displayName = 'Text'; diff --git a/frontend/components/ui/view.tsx b/frontend/components/ui/view.tsx new file mode 100644 index 0000000..ddbc066 --- /dev/null +++ b/frontend/components/ui/view.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from 'react'; +import { View as RNView, type ViewProps } from 'react-native'; + +export const View = forwardRef( + ({ style, ...otherProps }, ref) => { + return ( + + ); + }, +); +View.displayName = 'View'; diff --git a/frontend/hooks/useColor.ts b/frontend/hooks/useColor.ts new file mode 100644 index 0000000..8a875e7 --- /dev/null +++ b/frontend/hooks/useColor.ts @@ -0,0 +1,16 @@ +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Colors } from '@/theme/colors'; + +export function useColor( + colorName: keyof typeof Colors.light & keyof typeof Colors.dark, + props?: { light?: string; dark?: string }, +) { + const theme = useColorScheme() ?? 'light'; + const colorFromProps = props?.[theme]; + + if (colorFromProps) { + return colorFromProps; + } else { + return Colors[theme][colorName]; + } +} diff --git a/frontend/hooks/useColorScheme.ts b/frontend/hooks/useColorScheme.ts new file mode 100644 index 0000000..4cfea27 --- /dev/null +++ b/frontend/hooks/useColorScheme.ts @@ -0,0 +1,6 @@ +import { useColorScheme as useRNColorScheme } from 'react-native'; + +export function useColorScheme(): 'light' | 'dark' { + const scheme = useRNColorScheme(); + return scheme === 'dark' ? 'dark' : 'light'; +} diff --git a/frontend/hooks/useMyEvents.ts b/frontend/hooks/useMyEvents.ts new file mode 100644 index 0000000..a82e181 --- /dev/null +++ b/frontend/hooks/useMyEvents.ts @@ -0,0 +1,27 @@ +// frontend/hooks/useMyEvents.ts +import { useEffect, useState } from 'react'; +import api from '@/lib/api'; + +export function useMyEvents() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchMyEvents(); + }, []); + + async function fetchMyEvents() { + try { + const res = await api.get('/event/my-events'); + if (res.data?.success) { + setEvents(res.data.events); + } + } catch (err) { + console.log('Failed to fetch my events', err); + } finally { + setLoading(false); + } + } + + return { events, loading }; +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e63b49c..3a1d74b 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,9 +1,54 @@ import axios from 'axios'; +import { + getAccessToken, + getRefreshToken, + clearTokens, + storeTokens, +} from '@/services/token/token.storage'; const api = axios.create({ - baseURL: 'http://172.28.32.1:4000', + baseURL: 'http://10.10.2.183:4000', + timeout: 20000, - headers: { - 'Content-Type': 'application/json', - }, }); +api.interceptors.request.use(async (config) => { + const token = await getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); +api.interceptors.response.use( + (res) => res, + async (err) => { + const original = err.config; + if (err.response?.status === 401 && !original._retry) { + original._retry = true; + + try { + const refreshToken = await getRefreshToken(); + + const res = await axios.post( + 'http://10.10.2.183:4000/auth/refresh-token', + { refreshToken }, + ); + + const newAccessToken = res.data.accessToken; + if (!refreshToken) { + await clearTokens(); + return Promise.reject(err); + } + await storeTokens(newAccessToken, refreshToken); + + original.headers.Authorization = `Bearer ${newAccessToken}`; + + return api(original); + } catch { + await clearTokens(); + } + } + + return Promise.reject(err); + }, +); + export default api; diff --git a/frontend/package.json b/frontend/package.json index 5511659..51175b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,24 +5,29 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint" }, "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "axios": "^1.13.2", "expo": "~54.0.30", + "expo-barcode-scanner": "^13.0.1", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.12", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-media-library": "^18.2.1", "expo-router": "~6.0.21", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -31,17 +36,23 @@ "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", "lucide-react-native": "^0.562.0", + "qrcode": "^1.5.4", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-reanimated": "~4.1.1", - "react-native-safe-area-context": "~5.6.0", + "react-native-razorpay": "^2.3.1", + "react-native-reanimated": "~4.1.6", + "react-native-reanimated-carousel": "^4.0.3", + "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.1", + "react-native-toast-message": "^2.3.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", diff --git a/frontend/screens/auth/ForgetPasswordScreen.tsx b/frontend/screens/auth/ForgetPasswordScreen.tsx new file mode 100644 index 0000000..5c57562 --- /dev/null +++ b/frontend/screens/auth/ForgetPasswordScreen.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + ActivityIndicator, + Alert, +} from 'react-native'; + +import api from '@/lib/api'; + +export default function ForgotPasswordScreen() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + + const handleForgotPassword = async () => { + if (!email) { + Alert.alert('Error', 'Please enter your email'); + return; + } + + try { + setLoading(true); + + const res = await api.post(`/auth/forget-password`, { email }); + + Alert.alert('Success', res.data.message); + } catch (err: any) { + Alert.alert( + 'Error', + err.response?.data?.message || 'Something went wrong', + ); + } finally { + setLoading(false); + } + }; + + return ( + + Forgot Password + + + + + {loading ? ( + + ) : ( + Send Reset Link + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 20, + }, + + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + + input: { + borderWidth: 1, + borderColor: '#ccc', + padding: 12, + borderRadius: 6, + marginBottom: 20, + }, + + button: { + backgroundColor: '#007BFF', + padding: 15, + borderRadius: 6, + alignItems: 'center', + }, + + buttonText: { + color: '#fff', + fontWeight: 'bold', + }, +}); diff --git a/frontend/screens/auth/IntroScreen.tsx b/frontend/screens/auth/IntroScreen.tsx index acd5d5d..9847f04 100644 --- a/frontend/screens/auth/IntroScreen.tsx +++ b/frontend/screens/auth/IntroScreen.tsx @@ -1,78 +1,50 @@ -import { View, Text, StyleSheet, Pressable } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { Image } from 'react-native'; +import { Onboarding } from '@/components/ui/onboarding'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { router } from 'expo-router'; - export default function IntroScreen() { - const handleGetStarted = async () => { - await AsyncStorage.setItem('hasSeenIntro', 'true'); + const steps = [ + { + id: 'events', + title: 'Discover Events', + description: 'Find tech events happening around you.', + image: ( + + ), + }, + { + id: 'connect', + title: 'Connect with Developers', + description: 'Meet people who share your passion for coding.', + image: ( + + ), + }, + { + id: 'community', + title: 'Build Together', + description: 'Join a growing community and build amazing things.', + image: ( + + ), + }, + ]; + function finishIntro() { + AsyncStorage.setItem('hasSeenIntro', 'false'); router.push('/(auth)/phone'); - }; - - return ( - - - - Join the Awesome World Events - - - - Discover and join events around you using a simple and secure mobile - experience. - + } - - Let’s Start - - - - ); + return ; } - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: '#c2dcc6ff', - }, - - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 20, - }, - - title: { - fontSize: 22, - fontWeight: '700', - textAlign: 'center', - marginBottom: 10, - color: '#111', - }, - - highlight: { - color: '#4CAF50', - }, - - description: { - fontSize: 14, - textAlign: 'center', - color: '#555', - marginBottom: 24, - lineHeight: 20, - }, - - button: { - width: '100%', - backgroundColor: '#000', - paddingVertical: 14, - borderRadius: 14, - alignItems: 'center', - }, - - buttonText: { - color: '#FFF', - fontSize: 16, - fontWeight: '600', - }, -}); diff --git a/frontend/screens/auth/PhoneScreen.tsx b/frontend/screens/auth/PhoneScreen.tsx index f197fce..debb33e 100644 --- a/frontend/screens/auth/PhoneScreen.tsx +++ b/frontend/screens/auth/PhoneScreen.tsx @@ -17,17 +17,24 @@ export default function PhoneScreen() { const fullPhoneNumber = `+91${phone}`; const res = await sendOtp(fullPhoneNumber); - if (res.success) { + if (res?.success) { router.push({ pathname: '/(auth)/otp', params: { phone: fullPhoneNumber }, }); } } catch (err: any) { - const data = err?.response?.data; - if (data.next == 'login') { + console.error('OTP error:', err?.response?.data || err); + + const next = err?.response?.data?.next; + + if (next === 'login') { router.push('/(auth)/login'); + return; } + + // Optional: show a message + // Alert.alert("Error", "Unable to send OTP. Try again."); } finally { setLoading(false); } diff --git a/frontend/screens/auth/RegisterScreen.tsx b/frontend/screens/auth/RegisterScreen.tsx index f3bde02..a9217b1 100644 --- a/frontend/screens/auth/RegisterScreen.tsx +++ b/frontend/screens/auth/RegisterScreen.tsx @@ -4,13 +4,13 @@ import { TextInput, TouchableOpacity, StyleSheet, - SafeAreaView, Alert, } from 'react-native'; import { useState } from 'react'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { registerUser } from '@/services/auth/otp.service'; import { storeTokens } from '@/services/token/token.storage'; +import { SafeAreaView } from 'react-native-safe-area-context'; const INTERESTS = [ 'Technology', @@ -53,6 +53,10 @@ export default function RegisterScreen() { setStep(2); }; + const handlePrevious = () => { + setStep(1); + }; + const handleFinish = async () => { if (!otpId) { Alert.alert('Error', 'OTP session expired'); @@ -86,13 +90,19 @@ export default function RegisterScreen() { }); if (res?.success) { + await storeTokens(res.accessToken, res.refreshToken); router.replace('/(tabs)/home'); - storeTokens(res.accesstoken, res.refreshtoken); } else { + console.log('REGISTER ERROR:', res); Alert.alert('Registration failed', res?.message || 'Try again'); } - } catch (err) { - Alert.alert('Error', 'Something went wrong'); + } catch (err: any) { + console.log('REGISTER EXCEPTION:', err); + + Alert.alert( + 'Error', + err?.message || 'Network error or server unreachable', + ); } }; @@ -153,6 +163,12 @@ export default function RegisterScreen() { {step === 2 && ( <> + + Previous + Finish setup Select interests @@ -214,6 +230,18 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', }, + previousBtn: { + borderWidth: 1, + borderColor: '#000', + padding: 14, + borderRadius: 10, + marginTop: 10, + alignItems: 'center', + }, + previousText: { + color: '#000', + fontWeight: '600', + }, title: { fontSize: 22, fontWeight: '600', diff --git a/frontend/screens/auth/ResetPasswordScreen.tsx b/frontend/screens/auth/ResetPasswordScreen.tsx new file mode 100644 index 0000000..714e964 --- /dev/null +++ b/frontend/screens/auth/ResetPasswordScreen.tsx @@ -0,0 +1,133 @@ +import api from '@/lib/api'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Text, + TextInput, + TouchableOpacity, + View, + StyleSheet, +} from 'react-native'; + +export default function ResetPasswordScreen() { + const router = useRouter(); + const { token } = useLocalSearchParams<{ token: string }>(); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!token) { + Alert.alert('Error', 'Invalid or missing token'); + } + }, [token]); + + const handleResetPassword = async () => { + if (!password || !confirmPassword) { + Alert.alert('Error', 'Please fill all fields'); + return; + } + + if (password !== confirmPassword) { + Alert.alert('Error', 'Passwords do not match'); + return; + } + + if (password.length < 8) { + Alert.alert('Error', 'Password must be at least 8 characters'); + return; + } + + try { + setLoading(true); + + const res = await api.post('/auth/reset-password', { + token, + newPassword: password, + }); + + Alert.alert('Success', res.data.message); + + router.replace('/login'); + } catch (err: any) { + Alert.alert( + 'Error', + err.response?.dat?.message || 'Something went wrong', + ); + } finally { + setLoading(false); + } + }; + + return ( + + Reset Password + + + + + + + {loading ? ( + + ) : ( + Reset Password + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 20, + }, + + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + + input: { + borderWidth: 1, + borderColor: '#ccc', + padding: 12, + borderRadius: 6, + marginBottom: 20, + }, + + button: { + backgroundColor: '#007BFF', + padding: 15, + borderRadius: 6, + alignItems: 'center', + }, + + buttonText: { + color: '#fff', + fontWeight: 'bold', + }, +}); diff --git a/frontend/screens/auth/loginScreen.tsx b/frontend/screens/auth/loginScreen.tsx index 3ebb6d0..7ce15e6 100644 --- a/frontend/screens/auth/loginScreen.tsx +++ b/frontend/screens/auth/loginScreen.tsx @@ -4,12 +4,13 @@ import { TextInput, TouchableOpacity, StyleSheet, - SafeAreaView, } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useState } from 'react'; import { useRouter } from 'expo-router'; import { loginUser } from '@/services/auth/otp.service'; import { storeTokens } from '@/services/token/token.storage'; +import { showSuccess, showError } from '@/utils/toast'; export default function LoginScreen() { const router = useRouter(); @@ -18,12 +19,19 @@ export default function LoginScreen() { const [password, setPassword] = useState(''); async function handleLogin() { - const res = await loginUser({ phoneNumber, password }); - console.log(res); + try { + const res = await loginUser({ phoneNumber, password }); - if (res?.success) { - router.replace('/(tabs)/home'); - storeTokens(res.accesstoken, res.refreshtoken); + if (res?.success) { + await storeTokens(res.accessToken, res.refreshToken); + showSuccess('Logged in successfully'); + router.replace('/(tabs)/home'); + } + } catch (err: any) { + const message = + err?.response?.data?.message || 'Login failed. Try again.'; + + showError(message); } } @@ -59,6 +67,9 @@ export default function LoginScreen() { Create new account + router.push('/(auth)/forgetPassword')}> + Forgot Password? + ); } diff --git a/frontend/screens/events/BoostEvent.tsx b/frontend/screens/events/BoostEvent.tsx new file mode 100644 index 0000000..89258d1 --- /dev/null +++ b/frontend/screens/events/BoostEvent.tsx @@ -0,0 +1,44 @@ +import api from '@/lib/api'; +import { useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { TextInput } from 'react-native-gesture-handler'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Linking } from 'react-native'; +export default function EventBoostScrees() { + const [duration, setDuration] = useState(''); + const { id } = useLocalSearchParams(); + async function handleBoost() { + try { + if (!duration) return; + + const res = await api.post('/boost/purchase', { + eventId: id, + duration: Number(duration), + }); + + Linking.openURL(res.data.url); + } catch (err) { + console.log('Boost error', err); + } + } + + return ( + + + + + { + handleBoost(); + }} + > + boost + + + ); +} diff --git a/frontend/screens/events/CreateEventScreen.tsx b/frontend/screens/events/CreateEventScreen.tsx index 6970325..55358e7 100644 --- a/frontend/screens/events/CreateEventScreen.tsx +++ b/frontend/screens/events/CreateEventScreen.tsx @@ -1,57 +1,437 @@ -import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { useRouter } from 'expo-router'; - +import api from '@/lib/api'; +import { useState } from 'react'; +import { + View, + Pressable, + TextInput, + Text, + ScrollView, + Image, + StyleSheet, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { Switch } from '@/components/ui/switch'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import { DateTimePickerAndroid } from '@react-native-community/datetimepicker'; +import { showError } from '@/utils/toast'; +import { router } from 'expo-router'; export default function CreateEventScreen() { - const router = useRouter(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [images, setImages] = useState([]); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [isFree, setIsFree] = useState(true); + const [price, setPrice] = useState(''); + const [location, setLocation] = useState(''); + const [capacity, setCapacity] = useState(''); + const [category, setCategory] = useState(''); + const [rules, setRules] = useState(''); + const [loading, setLoading] = useState(false); + + async function pickImages() { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + showError('permisson needed bro'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsMultipleSelection: true, + selectionLimit: 4, + mediaTypes: ['images'], + quality: 0.8, + }); + + if (!result.canceled) { + const selected = result.assets.map((assets) => assets.uri); + setImages(selected); + } + } + async function handleEvent() { + try { + setLoading(true); + const form = new FormData(); + + form.append('title', title); + form.append('description', description); + images.forEach((uri, index) => { + form.append('images', { + uri: uri.startsWith('file://') ? uri : `file://${uri}`, + name: `image_${index}.jpg`, + type: 'image/jpeg', + } as any); + }); + form.append('startDate', startDate?.toISOString() || ''); + form.append('endDate', endDate?.toISOString() || ''); + form.append('isFree', String(isFree)); + form.append('price', isFree ? '0' : price); + form.append('location', location); + form.append('capacity', capacity); + form.append('category', category); + form.append('rules', rules); + + const res = await api.post('event/create-event', form, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + console.log(res.data); + if (res.data.success) { + setTitle(''); + setDescription(''); + setImages([]); + setStartDate(null); + setEndDate(null); + setLocation(''); + setCategory(''); + setRules(''); + setPrice(''); + + router.replace('/(tabs)/events'); + } + } catch (err) { + console.log('upload failed:', err); + } finally { + setLoading(false); + } + } + + function removeImage(index: number) { + setImages((prev) => prev.filter((_, i) => i !== index)); + } + const openPicker = (type: 'start' | 'end') => { + const current = + type === 'start' ? startDate || new Date() : endDate || new Date(); + + DateTimePickerAndroid.open({ + value: current, + mode: 'date', + is24Hour: true, + onChange: (event, selectedDate) => { + if (event.type === 'dismissed' || !selectedDate) return; + + DateTimePickerAndroid.open({ + value: selectedDate, + mode: 'time', + is24Hour: true, + onChange: (event2, selectedTime) => { + if (event2.type === 'dismissed' || !selectedTime) return; + + const finalDate = new Date(selectedDate); + finalDate.setHours(selectedTime.getHours()); + finalDate.setMinutes(selectedTime.getMinutes()); + + if (type === 'start') { + setStartDate(finalDate); + } else { + setEndDate(finalDate); + } + }, + }); + }, + }); + }; return ( - - - router.back()}> - Back - - - Create Event - - - - - - - Event creation form will go here. - - - + + + + Create New Event + + {/* Text Inputs */} + + Event Title + + + + + Description + + + + {/* Date Pickers */} + + openPicker('start')} + > + Starts + + {startDate + ? startDate.toLocaleString([], { + dateStyle: 'short', + timeStyle: 'short', + }) + : 'Select start'} + + + + openPicker('end')}> + Ends + + {endDate + ? endDate.toLocaleString([], { + dateStyle: 'short', + timeStyle: 'short', + }) + : 'Select end'} + + + + + Is this a free event? + + + + {isFree ? 'Free Event' : 'Paid Event'} + + + + + + {!isFree && ( + + Ticket Price + + + )} + + + Location + + + + Capacity + + + + Category + + + + Rules (optional) + + + + {/* Image Selection */} + + + Photos + + + Add Photos + + + + + {images.map((uri, ind) => ( + + removeImage(ind)} + style={styles.removeBadge} + > + Γ— + + + + ))} + + + + {/* Submit Button */} + + {loading ? ( + + ) : ( + Create Event + )} + + + + ); } + const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#fff', + backgroundColor: '#F8F9FA', + }, + inner: { + flex: 1, + paddingHorizontal: 20, }, header: { - height: 56, - paddingHorizontal: 16, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderBottomWidth: 0.5, - borderBottomColor: '#e5e7eb', + fontSize: 24, + fontWeight: 'bold', + color: '#1A1A1A', + marginVertical: 20, }, - backText: { - color: '#22c55e', - fontSize: 16, + inputGroup: { + marginBottom: 20, }, - title: { - fontSize: 16, + label: { + fontSize: 14, fontWeight: '600', + color: '#4B5563', + marginBottom: 8, + }, + input: { + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 12, + padding: 12, + fontSize: 16, + color: '#1F2937', + }, + textArea: { + height: 100, + textAlignVertical: 'top', }, - content: { + row: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 20, + gap: 12, + }, + dateBox: { flex: 1, - padding: 16, + backgroundColor: '#FFFFFF', + padding: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + dateLabel: { + fontSize: 12, + color: '#9CA3AF', + marginBottom: 4, }, - placeholder: { - color: '#6b7280', + dateText: { fontSize: 14, + fontWeight: '500', + }, + section: { + marginBottom: 30, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + linkText: { + color: '#2563EB', + fontWeight: '600', + }, + imageScroll: { + flexDirection: 'row', + }, + imageWrapper: { + position: 'relative', + marginRight: 12, + marginTop: 5, + }, + thumbnail: { + width: 90, + height: 90, + borderRadius: 12, + }, + removeBadge: { + position: 'absolute', + top: -8, + right: -8, + backgroundColor: '#EF4444', + width: 24, + height: 24, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }, + removeText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + lineHeight: 18, + }, + submitButton: { + backgroundColor: '#2563EB', + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 10, + marginBottom: 40, + }, + submitButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: 'bold', + }, + switchRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', }, }); diff --git a/frontend/screens/events/EventDetailScreen.tsx b/frontend/screens/events/EventDetailScreen.tsx index b50c680..e6cd7b5 100644 --- a/frontend/screens/events/EventDetailScreen.tsx +++ b/frontend/screens/events/EventDetailScreen.tsx @@ -1,12 +1,323 @@ -import { View, Text } from 'react-native'; -import { useLocalSearchParams } from 'expo-router'; +import { + View, + Text, + Pressable, + StyleSheet, + ScrollView, + Image, + Dimensions, +} from 'react-native'; +import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import EventDetailSkeleton from '@/components/comps/skeletonEvent'; +import Carousel from 'react-native-reanimated-carousel'; +import api from '@/lib/api'; +import { Linking } from 'react-native'; + +interface EventType { + id: string; + title: string; + description: string; + category: string; + location: string; + startDate: string; + endDate: string; + price: string; + status: string; + rules: string; + capacity: number; + image: { + id: string; + imageUrl: string; + }[]; +} export default function EventDetailScreen() { + const [event, setEvent] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + const [isHost, setIsHost] = useState(false); + const { id } = useLocalSearchParams(); + const eventId = Array.isArray(id) ? id[0] : id; + const { width } = Dimensions.get('window'); + + useEffect(() => { + if (!eventId) return; + fetchEvent(); + }, [eventId]); + + useFocusEffect( + useCallback(() => { + fetchEvent(); + }, [eventId]), + ); + + async function fetchEvent() { + const res = await api.get(`/event/getEvent/${eventId}`); + console.log(res.data); + + setEvent(res.data.event); + setIsHost(res.data.host); + } + []; + async function handleJoin() { + try { + const res = await api.post(`/event/join-event/${eventId}`); + + if (res.data.pay && res.data.url) { + await Linking.openURL(res.data.url); + return; + } + setEvent((prev) => + prev + ? { + ...prev, + capacity: prev.capacity - 1, + status: 'joined', + } + : prev, + ); + + alert('Successfully joined event'); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to join event'); + } + } + + if (!event) { + return ; + } return ( - - Event ID: {id} + + {event.image?.length > 0 && ( + ( + + )} + /> + )} + + {event.title} + + {event.location} + + {event.description} + + {event.startDate} + {event.endDate} + + + Category: {event.category} + Status: {event.status} + + setShowConfirm(true)} + > + + {event.status === 'joined' + ? 'Joined' + : event.status === 'published' + ? Number(event.price) > 0 + ? `Join Event Β· β‚Ή${event.price}` + : 'Join Event Β· Free' + : 'Not Available'} + + + {isHost && ( + router.push(`/(tabs)/events/${id}/scan`)} + > + scan for joinees + + )} + {isHost && ( + router.push(`/(tabs)/events/${id}/boost`)} + > + Boost Event + + )} + + {showConfirm && ( + + + Join Event? + + Are you sure you want to join this event? + + These are the rules + + + {event.rules} + + + + setShowConfirm(false)} + > + Cancel + + + { + setShowConfirm(false); + handleJoin(); + }} + > + Confirm + + + + + )} ); } +const styles = StyleSheet.create({ + container: { + padding: 16, + backgroundColor: '#fff', + flex: 1, + }, + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 6, + }, + location: { + color: '#666', + marginBottom: 12, + }, + description: { + fontSize: 15, + lineHeight: 22, + marginBottom: 16, + }, + date: { + fontSize: 14, + marginBottom: 4, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 24, + }, + joinButton: { + backgroundColor: '#000', + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + }, + joinText: { + color: '#fff', + fontSize: 16, + fontWeight: '500', + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + + confirmBox: { + width: '80%', + backgroundColor: '#fff', + borderRadius: 10, + padding: 16, + }, + + confirmTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + + confirmText: { + color: '#555', + marginBottom: 16, + }, + + confirmActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + }, + + cancelBtn: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + + confirmBtn: { + backgroundColor: '#000', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 6, + }, + scanBtn: { + backgroundColor: '#00B894', + paddingVertical: 12, + paddingHorizontal: 18, + borderRadius: 10, + alignItems: 'center', + marginTop: 10, + + shadowColor: '#000', + shadowOpacity: 0.12, + shadowRadius: 3, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + + scanText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + boostBtn: { + backgroundColor: '#6C5CE7', + paddingVertical: 12, + paddingHorizontal: 18, + borderRadius: 10, + alignItems: 'center', + marginTop: 12, + + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 3, + }, + + boostText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/frontend/screens/events/EventScan.tsx b/frontend/screens/events/EventScan.tsx new file mode 100644 index 0000000..f01e32a --- /dev/null +++ b/frontend/screens/events/EventScan.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useLocalSearchParams } from 'expo-router'; +import api from '@/lib/api'; + +export default function ScanTicketsScreen() { + const { id: eventId } = useLocalSearchParams(); + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + useEffect(() => { + if (!permission) { + requestPermission(); + } + }, []); + + const handleScan = async ({ data }: { data: string }) => { + if (scanned) return; + + setScanned(true); + + try { + const res = await api.post('/event/attendance', { + qrCode: data, + eventId, + }); + + if (res.data.success) { + alert('Entry allowed '); + } else { + alert(res.data.message); + } + } catch (err: any) { + alert(err.response?.data?.message || 'Scan failed'); + } + + setTimeout(() => setScanned(false), 2000); + }; + + if (!permission) { + return Requesting camera permission...; + } + + if (!permission.granted) { + return No camera access; + } + + return ( + + + + Scan Ticket QR + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + overlayText: { + position: 'absolute', + bottom: 40, + alignSelf: 'center', + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/frontend/screens/events/EventUpdateScreen.tsx b/frontend/screens/events/EventUpdateScreen.tsx new file mode 100644 index 0000000..0c56405 --- /dev/null +++ b/frontend/screens/events/EventUpdateScreen.tsx @@ -0,0 +1,523 @@ +import api from '@/lib/api'; +import { useEffect, useState } from 'react'; +import { + View, + Pressable, + TextInput, + Text, + ScrollView, + Image, + StyleSheet, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { Switch } from '@/components/ui/switch'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import { DateTimePickerAndroid } from '@react-native-community/datetimepicker'; +import { showError } from '@/utils/toast'; +import { useLocalSearchParams, router } from 'expo-router'; +export default function UpdateEventScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [existingImages, setExistingImages] = useState([]); + const [newImages, setNewImages] = useState([]); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [isFree, setIsFree] = useState(true); + const [price, setPrice] = useState(''); + const [location, setLocation] = useState(''); + const [capacity, setCapacity] = useState(''); + const [category, setCategory] = useState(''); + const [rules, setRules] = useState(''); + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(true); + + const { refresh } = useLocalSearchParams<{ refresh: string }>(); + useEffect(() => { + if (!id) return; + fetchEvent(); + }, [id, refresh]); + + const fetchEvent = async () => { + try { + const res = await api.get(`event/getEvent/${id}`); + const e = res.data.event; + + setTitle(e.title || ''); + setDescription(e.description || ''); + setExistingImages(e.image?.map((img: any) => img.imageUrl) || []); + setStartDate(e.startDate ? new Date(e.startDate) : null); + setEndDate(e.endDate ? new Date(e.endDate) : null); + setIsFree(Boolean(e.isFree)); + setPrice(e.price ? String(e.price) : ''); + setLocation(e.location || ''); + setCapacity(e.capacity ? String(e.capacity) : ''); + setCategory(e.category || ''); + setRules(e.rules || ''); + } finally { + setFetching(false); + } + }; + + async function pickImages() { + const remaining = 4 - existingImages.length; + + if (remaining <= 0) { + showError('You can upload only 4 images'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsMultipleSelection: true, + selectionLimit: remaining, + mediaTypes: ['images'], + quality: 0.8, + }); + + if (!result.canceled) { + setNewImages(result.assets.map((a) => a.uri)); + } + } + + function removeExistingImage(index: number) { + setExistingImages((prev) => prev.filter((_, i) => i !== index)); + } + + function removeNewImage(index: number) { + setNewImages((prev) => prev.filter((_, i) => i !== index)); + } + + async function handleEvent() { + setLoading(true); + try { + if (existingImages.length + newImages.length > 4) { + showError('Maximum 4 images allowed'); + setLoading(false); + return; + } + const form = new FormData(); + + form.append('title', title); + form.append('description', description); + form.append('isFree', String(isFree)); + form.append('price', isFree ? '0' : price); + form.append('location', location); + form.append('capacity', capacity); + form.append('category', category); + form.append('rules', rules); + if (startDate) { + form.append('startDate', startDate.toISOString()); + } + + if (endDate) { + form.append('endDate', endDate.toISOString()); + } + + form.append('existingImages', JSON.stringify(existingImages)); + + newImages.forEach((uri, index) => { + form.append('images', { + uri: uri.startsWith('file://') ? uri : `file://${uri}`, + name: `image_${index}.jpg`, + type: 'image/jpeg', + } as any); + }); + + const res = await api.put(`/event/update/${id}`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + if (res.data?.success) { + router.replace({ + pathname: '/(tabs)/events', + params: { refresh: Date.now().toString() }, + }); + } + + console.log('UPDATE RESPONSE:', res.data); + } catch (err) { + console.log('upload failed:', err); + } finally { + setLoading(false); + } + } + + const openPicker = (type: 'start' | 'end') => { + const current = + type === 'start' ? startDate || new Date() : endDate || new Date(); + + DateTimePickerAndroid.open({ + value: current, + mode: 'date', + is24Hour: true, + onChange: (event, selectedDate) => { + if (event.type === 'dismissed' || !selectedDate) return; + + DateTimePickerAndroid.open({ + value: selectedDate, + mode: 'time', + is24Hour: true, + onChange: (event2, selectedTime) => { + if (event2.type === 'dismissed' || !selectedTime) return; + + const finalDate = new Date(selectedDate); + finalDate.setHours(selectedTime.getHours()); + finalDate.setMinutes(selectedTime.getMinutes()); + + if (type === 'start') { + setStartDate(finalDate); + } else { + setEndDate(finalDate); + } + if (type === 'end' && startDate && finalDate < startDate) { + showError('End date cannot be before start date'); + return; + } + }, + }); + }, + }); + }; + + return ( + + + + Update Event + + {fetching ? ( + + ) : ( + + + Event Title + + + + + Description + + + + {/* Date Pickers */} + + openPicker('start')} + > + Starts + + {startDate + ? startDate.toLocaleString([], { + dateStyle: 'short', + timeStyle: 'short', + }) + : 'Select start'} + + + + openPicker('end')} + > + Ends + + {endDate + ? endDate.toLocaleString([], { + dateStyle: 'short', + timeStyle: 'short', + }) + : 'Select end'} + + + + + Is this a free event? + + + + {isFree ? 'Free Event' : 'Paid Event'} + + + { + setIsFree(value); + if (value) { + setPrice(''); + } + }} + /> + + + {!isFree && ( + + Ticket Price + + + )} + + + Location + + + + Capacity + + + + Category + + + + Rules (optional) + + + + {/* Image Selection */} + + + Photos + + + Add Photos + + + + + {/* {images.map((uri, ind) => ( + + removeImage(ind)} + style={styles.removeBadge} + > + Γ— + + + + ))} */} + {existingImages.map((uri, ind) => ( + + removeExistingImage(ind)} + style={styles.removeBadge} + > + Γ— + + + + ))} + + {newImages.map((uri, ind) => ( + + removeNewImage(ind)} + style={styles.removeBadge} + > + Γ— + + + + ))} + + + + )} + + {/* Submit Button */} + + {loading ? ( + + ) : ( + Update + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8F9FA', + }, + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + inner: { + flex: 1, + paddingHorizontal: 20, + }, + header: { + fontSize: 24, + fontWeight: 'bold', + color: '#1A1A1A', + marginVertical: 20, + }, + inputGroup: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#4B5563', + marginBottom: 8, + }, + input: { + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 12, + padding: 12, + fontSize: 16, + color: '#1F2937', + }, + textArea: { + height: 100, + textAlignVertical: 'top', + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 20, + gap: 12, + }, + dateBox: { + flex: 1, + backgroundColor: '#FFFFFF', + padding: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + dateLabel: { + fontSize: 12, + color: '#9CA3AF', + marginBottom: 4, + }, + dateText: { + fontSize: 14, + fontWeight: '500', + }, + section: { + marginBottom: 30, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + linkText: { + color: '#2563EB', + fontWeight: '600', + }, + imageScroll: { + flexDirection: 'row', + }, + imageWrapper: { + position: 'relative', + marginRight: 12, + marginTop: 5, + }, + thumbnail: { + width: 90, + height: 90, + borderRadius: 12, + }, + removeBadge: { + position: 'absolute', + top: -8, + right: -8, + backgroundColor: '#EF4444', + width: 24, + height: 24, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }, + removeText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + lineHeight: 18, + }, + submitButton: { + backgroundColor: '#2563EB', + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 10, + marginBottom: 40, + }, + submitButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: 'bold', + }, + switchRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, +}); diff --git a/frontend/screens/events/EventsScreen.tsx b/frontend/screens/events/EventsScreen.tsx index 23f4c00..ae6fba1 100644 --- a/frontend/screens/events/EventsScreen.tsx +++ b/frontend/screens/events/EventsScreen.tsx @@ -1,66 +1,336 @@ -import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + View, + Text, + ScrollView, + Pressable, + TextInput, + StyleSheet, + ImageBackground, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import api from '@/lib/api'; + +function EventSkeleton() { + return ( + + + + + + + + + + + ); +} + +function isPastEvent(endDate: string) { + return new Date(endDate).getTime() < Date.now(); +} export default function EventsScreen() { - const router = useRouter(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [cursor, setCursor] = useState<{ + startDate: string; + id: string; + } | null>(null); + + useEffect(() => { + fetchEvents(); + }, []); + + async function fetchEvents() { + if (loading || !hasMore) return; + + setLoading(true); + + try { + let url = '/event/my-events?limit=10'; + + if (cursor) { + url += `&cursor=${cursor.startDate}&id=${cursor.id}`; + } + + const res = await api.get(url); + console.log(`this is the fetch event api`); + console.log(res.data); + + if (res.data?.success) { + setEvents((prev) => [...prev, ...res.data.events]); + setHasMore(res.data.hasMore); + setCursor(res.data.nextCursor); + } + } catch (err) { + console.log('Failed to load events', err); + } finally { + setLoading(false); + } + } + + async function cancelEvent(eventId: string) { + try { + const res = await api.post(`/event/cancel/${eventId}`); + if (res.data?.success) { + fetchEvents(); + } + } catch (err: any) { + console.log('Failed to cancel event:', err.response?.data || err.message); + } + } return ( - + - Events + + Events + + router.push('/(tabs)/tickets/ticket')}> + My tickets + + + + router.push('/(tabs)/events/create')}> + Create + + - router.push('/(tabs)/events/create')}> - Create - + + + - + My Events - - You haven’t created any events yet. - - - - Joined Events - - Events you join will appear here. - - - + {loading && + Array.from({ length: 3 }).map((_, i) => )} + + {!loading && events.length === 0 && ( + No events found + )} + + {!loading && + events.map((event) => { + const past = isPastEvent(event.endDate); + + return ( + router.push(`/(tabs)/events/${event.id}`)} + > + + + + {past && ( + + PAST + + )} + + {event.title} + {event.location} + {event.startDate} + + + {!past && ( + { + e.stopPropagation(); + router.push(`/(tabs)/events/update/${event.id}`); + }} + > + Update + + )} + + {!past && event.status !== 'canceled' && ( + { + e.stopPropagation(); + cancelEvent(event.id); + }} + > + Cancel + + )} + + {event.status === 'canceled' && ( + Canceled + )} + + + + + + ); + })} + + ); } + +/* ---------- Styles ---------- */ + const styles = StyleSheet.create({ container: { flex: 1, - padding: 16, backgroundColor: '#fff', }, + header: { + padding: 16, + }, + + headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 24, + marginBottom: 12, }, + title: { - fontSize: 22, - fontWeight: '600', + fontSize: 24, + fontWeight: '700', }, + createText: { fontSize: 16, - color: '#22c55e', - fontWeight: '500', + fontWeight: '600', }, - section: { - marginBottom: 24, + + searchBox: { + backgroundColor: '#f3f4f6', + borderRadius: 12, + paddingHorizontal: 12, + height: 44, + justifyContent: 'center', + }, + + scrollContent: { + paddingHorizontal: 16, }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + + emptyText: { + color: '#6b7280', + }, + + eventCard: { + marginBottom: 14, + padding: 0, + overflow: 'hidden', + }, + + eventImage: { + height: 180, + justifyContent: 'flex-end', + }, + + eventImageRadius: { + borderRadius: 12, + }, + + overlay: { + backgroundColor: 'rgba(0,0,0,0.45)', + padding: 12, + }, + + eventTitle: { fontSize: 16, + fontWeight: '700', + color: '#fff', + }, + + eventLocation: { + color: '#e5e7eb', + marginTop: 4, + }, + + eventDate: { + color: '#d1d5db', + marginTop: 2, + fontSize: 12, + }, + + eventUpdate: { + marginTop: 8, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: '#3b82f6', + borderRadius: 6, + color: '#fff', + fontSize: 14, fontWeight: '600', - marginBottom: 8, }, - placeholder: { + + eventCancel: { + marginTop: 8, + paddingVertical: 6, + paddingHorizontal: 12, + backgroundColor: '#ef4444', + borderRadius: 6, + color: '#fff', fontSize: 14, - color: '#6b7280', + fontWeight: '600', + }, + + canceledText: { + color: '#fca5a5', + marginTop: 10, + fontWeight: '600', + }, + + pastBadge: { + alignSelf: 'flex-start', + backgroundColor: '#dc2626', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + marginBottom: 6, + }, + + pastBadgeText: { + color: '#fff', + fontSize: 12, + fontWeight: '700', + }, + + skeletonCard: { + marginBottom: 14, + padding: 12, + }, + + skeletonImage: { + borderRadius: 12, + }, + + skeletonTextWrapper: { + marginTop: 10, + }, + + skeletonSpacing: { + marginTop: 6, }, }); diff --git a/frontend/screens/home/HomeScreen.tsx b/frontend/screens/home/HomeScreen.tsx index 4d8fe8c..5a59230 100644 --- a/frontend/screens/home/HomeScreen.tsx +++ b/frontend/screens/home/HomeScreen.tsx @@ -1,58 +1,255 @@ -import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { useEffect, useState, useRef } from 'react'; +import { + View, + Text, + Pressable, + TextInput, + StyleSheet, + ImageBackground, + FlatList, +} from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import api from '@/lib/api'; + +function HomeSkeleton() { + return ( + + + + + + + + + ); +} export default function HomeScreen() { + const loadingRef = useRef(false); + + const [events, setEvents] = useState([]); + const [boostedEvents, setBoostedEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [cursor, setCursor] = useState(null); + + useEffect(() => { + fetchEvents(); + fetchBoosted(); + }, []); + + async function fetchBoosted() { + try { + const res = await api.get('/boost/active'); + console.log('inside fetch boosted'); + console.log(res.data); + setBoostedEvents(res.data.events || []); + } catch (err) { + console.log('Failed boosted fetch', err); + } + } + + async function fetchEvents() { + if (loadingRef.current || !hasMore) return; + + loadingRef.current = true; + setLoading(true); + + try { + let url = '/event/all-events?limit=10'; + + if (cursor) { + url += `&cursor=${encodeURIComponent( + new Date(cursor.startDate).toISOString(), + )}&id=${cursor.id}`; + } + + const res = await api.get(url); + + if (res.data.success) { + // setEvents((prev) => [...prev, ...res.data.events]); + setEvents((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + + const newEvents = res.data.events.filter( + (e: any) => !existingIds.has(e.id), + ); + + return [...prev, ...newEvents]; + }); + + setHasMore(res.data.hasMore); + setCursor(res.data.nextCursor); + } + } catch (err) { + console.log('Failed to load events', err); + } finally { + loadingRef.current = false; + setLoading(false); + } + } + return ( - + - Home - - - New Events - New events will appear here. + Events + + + - - Popular Events - Popular events will appear here. - - + item.id} + contentContainerStyle={styles.scrollContent} + onEndReached={fetchEvents} + onEndReachedThreshold={0.3} + removeClippedSubviews={true} + ListHeaderComponent={ + <> + {boostedEvents.length > 0 && ( + + sponsered + + item.id} + showsHorizontalScrollIndicator={false} + renderItem={({ item }) => ( + router.push(`/(tabs)/events/${item.id}`)} + style={styles.boostedCard} + > + + + {item.title} + + + + )} + /> + + )} + + All Events + + } + ListFooterComponent={ + loading ? ( + + ) : !hasMore ? ( + No more events + ) : null + } + renderItem={({ item: event }) => ( + router.push(`/(tabs)/events/${event.id}`)}> + + + + {event.title} + {event.location} + {event.startDate} + + + + + )} + /> + ); } + const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 16, - backgroundColor: '#fff', - }, - header: { - marginBottom: 16, - }, - title: { - fontSize: 22, - fontWeight: '600', - }, + container: { flex: 1, backgroundColor: '#fff' }, + + header: { padding: 16 }, + + title: { fontSize: 24, fontWeight: '700', marginBottom: 12 }, + searchBox: { - height: 44, - borderRadius: 8, backgroundColor: '#f3f4f6', - justifyContent: 'center', + borderRadius: 12, paddingHorizontal: 12, - marginBottom: 24, + height: 44, + justifyContent: 'center', }, - searchText: { - color: '#6b7280', - fontSize: 14, + + scrollContent: { paddingHorizontal: 16 }, + + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, }, - section: { - marginBottom: 24, + + emptyText: { color: '#6b7280', textAlign: 'center' }, + + eventCard: { + marginBottom: 14, + padding: 0, + overflow: 'hidden', }, - sectionTitle: { + + eventImage: { + height: 180, + justifyContent: 'flex-end', + }, + + eventImageRadius: { + borderRadius: 12, + }, + + overlay: { + backgroundColor: 'rgba(0,0,0,0.45)', + padding: 12, + }, + + eventTitle: { fontSize: 16, - fontWeight: '600', - marginBottom: 8, + fontWeight: '700', + color: '#fff', + }, + + eventLocation: { + color: '#e5e7eb', + marginTop: 4, }, - placeholder: { - fontSize: 14, - color: '#6b7280', + + eventDate: { + color: '#d1d5db', + marginTop: 2, + fontSize: 12, + }, + + skeletonCard: { marginBottom: 14, padding: 12 }, + + skeletonImage: { borderRadius: 12 }, + + skeletonTextWrapper: { marginTop: 10 }, + + skeletonSpacing: { marginTop: 6 }, + + boostedCard: { width: 220, marginRight: 12 }, + + boostedImage: { + height: 140, + justifyContent: 'flex-end', }, }); diff --git a/frontend/screens/payment/success.tsx b/frontend/screens/payment/success.tsx new file mode 100644 index 0000000..74500e8 --- /dev/null +++ b/frontend/screens/payment/success.tsx @@ -0,0 +1,24 @@ +import { View, Text } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useEffect } from 'react'; + +export default function PaymentSuccess() { + const router = useRouter(); + useEffect(() => { + setTimeout(() => { + router.replace('/'); + }, 3000); + }, []); + return ( + + Payment Successful + Your boost is now active + + ); +} diff --git a/frontend/screens/profile/ChangePasswordScreen.tsx b/frontend/screens/profile/ChangePasswordScreen.tsx new file mode 100644 index 0000000..c909172 --- /dev/null +++ b/frontend/screens/profile/ChangePasswordScreen.tsx @@ -0,0 +1,152 @@ +import { + Text, + TextInput, + Pressable, + StyleSheet, + Alert, + ActivityIndicator, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { useCallback, useState } from 'react'; +import api from '@/lib/api'; +import { useFocusEffect } from 'expo-router'; + +export default function ChangePasswordScreen() { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmNewPassword, setConfirmNewPassword] = useState(''); + + const [loading, setLoading] = useState(false); + + useFocusEffect( + useCallback(() => { + return () => { + setCurrentPassword(''); + setNewPassword(''); + setConfirmNewPassword(''); + }; + }, []), + ); + + async function handleChangePassword() { + if (!currentPassword || !newPassword || !confirmNewPassword) { + Alert.alert('Error', 'All fields are required'); + return; + } + + setLoading(true); + + try { + const res = await api.put('/auth/change-password', { + currentPassword, + newPassword, + confirmNewPassword, + }); + + Alert.alert( + 'Success', + res.data.message || 'Password changed successfully', + ); + + setCurrentPassword(''); + setNewPassword(''); + setConfirmNewPassword(''); + } catch (error: any) { + const message = + error?.response?.data?.message || 'Failed to change password'; + + Alert.alert('Error', message); + } finally { + setLoading(false); + } + } + + return ( + + Change Password + + + + + + + + [styles.button, pressed && styles.pressed]} + onPress={handleChangePassword} + disabled={loading} + > + {loading ? ( + + ) : ( + Update Password + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + padding: 20, + justifyContent: 'center', + }, + + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 30, + textAlign: 'center', + }, + + input: { + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 8, + padding: 14, + marginBottom: 16, + fontSize: 15, + }, + + button: { + backgroundColor: '#4f46e5', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 10, + }, + + buttonText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + + pressed: { + opacity: 0.8, + }, +}); diff --git a/frontend/screens/profile/EditProfileScreen.tsx b/frontend/screens/profile/EditProfileScreen.tsx new file mode 100644 index 0000000..4f79a08 --- /dev/null +++ b/frontend/screens/profile/EditProfileScreen.tsx @@ -0,0 +1,190 @@ +import { + View, + Text, + TextInput, + Pressable, + StyleSheet, + Image, + Alert, +} from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import { useEffect, useState } from 'react'; +import api from '@/lib/api'; +import { router } from 'expo-router'; + +export default function EditProfileScreen() { + const [form, setForm] = useState({ + name: '', + age: '', + gender: '', + interest: '', + }); + + const [avatar, setAvatar] = useState(null); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + loadProfile(); + }, []); + + const defaultAvatar = require('@/assets/images/OIP.jpeg'); + + async function loadProfile() { + const res = await api.get('/user/me'); + const u = res.data.user; + + setForm({ + name: u.name || '', + age: u.age?.toString() || '', + gender: u.gender || '', + interest: u.interests || '', + }); + + setAvatar(u.profileImageUrl || null); + } + + async function pickImage() { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permission.granted) { + Alert.alert('Permission required', 'Please allow photo access'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsMultipleSelection: true, + selectionLimit: 1, + mediaTypes: ['images'], + quality: 0.8, + }); + + if (!result.canceled) { + setAvatar(result.assets[0].uri); + } + } + + async function uploadAvatar() { + if (!avatar || avatar.startsWith('http')) return; + + const formData = new FormData(); + + formData.append('avatar', { + uri: avatar.startsWith('file://') ? avatar : `file://${avatar}`, + name: 'avatar.jpg', + type: 'image/jpeg', + } as any); + + await api.post('/user/me/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + } + + async function handleSave() { + try { + setUploading(true); + await uploadAvatar(); + await api.put('/user/me/edit', { + name: form.name, + age: Number(form.age), + gender: form.gender, + }); + + Alert.alert('Success', 'Profile updated successfully'); + router.back(); + } catch (err) { + console.error(err); + Alert.alert('Error', 'Failed to update profile'); + } finally { + setUploading(false); + } + } + //comment + return ( + + + + + + Edit + + + + setForm({ ...form, name: v })} + style={styles.input} + /> + + setForm({ ...form, age: v })} + style={styles.input} + /> + + setForm({ ...form, gender: v })} + style={styles.input} + /> + + + + {uploading ? 'Saving...' : 'Save Changes'} + + + + ); +} +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: '#fff', + }, + input: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + button: { + backgroundColor: '#4f46e5', + padding: 16, + borderRadius: 8, + alignItems: 'center', + }, + btnText: { + color: '#fff', + fontWeight: '600', + }, + avatarContainer: { + alignItems: 'center', + marginBottom: 24, + }, + avatar: { + width: 100, + height: 100, + borderRadius: 50, + }, + editAvatar: { + marginTop: 8, + }, + editAvatarText: { + color: '#4f46e5', + fontWeight: '600', + }, +}); diff --git a/frontend/screens/profile/ProfileScreen.tsx b/frontend/screens/profile/ProfileScreen.tsx index 5c0550e..5a39100 100644 --- a/frontend/screens/profile/ProfileScreen.tsx +++ b/frontend/screens/profile/ProfileScreen.tsx @@ -1,6 +1,110 @@ -import { View, Text, StyleSheet, Pressable } from 'react-native'; +import api from '@/lib/api'; +import { + View, + Text, + StyleSheet, + Pressable, + Alert, + Image, + ActivityIndicator, +} from 'react-native'; +import { getRefreshToken, clearTokens } from '@/services/token/token.storage'; +import { router } from 'expo-router'; +import { useEffect, useState } from 'react'; + +export interface UserProfileType { + id: string; + name: string; + email: string | null; + phoneNumber: string | null; + age: number | null; + gender: string | null; + interests: string[] | null; + profileImageUrl: string | null; + isPhoneVerified: boolean; + createdAt: string; +} export default function ProfileScreen() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + fetchProfile(); + }, []); + + const defaultAvatar = require('@/assets/images/OIP.jpeg'); + + async function fetchProfile() { + try { + setLoading(true); + + const res = await api.get('/user/me'); + console.log(res.data); + console.log('Avatar URL:', res.data.user.profileImageUrl); + + setUser(res.data.user); + } catch (err) { + console.error('Failed to fetch profile', err); + } finally { + setLoading(false); + } + } + + const handleLogout = () => { + Alert.alert('Log out', 'Are you sure bro?', [ + { + text: 'Cancel', + style: 'destructive', + }, + { + text: 'Log out', + style: 'destructive', + onPress: async () => { + const refreshToken = await getRefreshToken(); + const res = await api.post('/auth/logout', { refreshToken }); + console.log('inside logout fn'); + console.log(res.data); + if (res.data.success) { + await clearTokens(); + router.push('/(auth)'); + } + }, + }, + ]); + }; + + async function handleLogoutTest() { + const refreshToken = await getRefreshToken(); + const res = await api.post('/auth/logout', { refreshToken }); + console.log('inside logout fn'); + console.log(res.data); + if (res.data.success) { + await clearTokens(); + router.push('/(auth)/login'); + } + } + + const handleSettings = () => { + router.push('/profile/setting'); + }; + + const handleProfile = async () => { + router.push('/profile/edit'); + }; + + if (loading) { + return ( + + + + ); + } + + if (!user) { + return Failed to load profile; + } + return ( @@ -8,29 +112,46 @@ export default function ProfileScreen() { - + + - Your Name - +91 XXXXX XXXXX + {user.name} + + {user.phoneNumber || 'Phone not added'} + - + Edit Profile - + Settings Help & Support + + + Log Out + + + Log out for web + ); } + const styles = StyleSheet.create({ container: { flex: 1, @@ -48,13 +169,13 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', marginBottom: 32, + gap: 16, }, avatar: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#e5e7eb', - marginRight: 16, }, name: { fontSize: 16, @@ -77,4 +198,13 @@ const styles = StyleSheet.create({ rowText: { fontSize: 15, }, + LogOut: { + fontSize: 15, + color: 'red', + }, + loaderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, }); diff --git a/frontend/screens/profile/SettingScreen.tsx b/frontend/screens/profile/SettingScreen.tsx new file mode 100644 index 0000000..ce18bde --- /dev/null +++ b/frontend/screens/profile/SettingScreen.tsx @@ -0,0 +1,68 @@ +import { Pressable, Text, View, StyleSheet } from 'react-native'; +import { router } from 'expo-router'; + +export default function SettingScreen() { + const handleChangePassword = () => { + router.push('/profile/change-password'); + }; + + const handleVerifyEmail = () => { + router.push('/profile/verify-email'); + }; + + return ( + + Settings + + + [styles.row, pressed && styles.pressed]} + onPress={handleVerifyEmail} + > + Verify Email + + + [styles.row, pressed && styles.pressed]} + onPress={handleChangePassword} + > + Change Password + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + padding: 16, + }, + + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 24, + }, + + section: { + borderTopWidth: 0.5, + borderTopColor: '#e5e7eb', + }, + + row: { + paddingVertical: 18, + borderBottomWidth: 0.5, + borderBottomColor: '#e5e7eb', + }, + + rowText: { + fontSize: 16, + fontWeight: '500', + }, + + pressed: { + backgroundColor: '#f3f4f6', + }, +}); diff --git a/frontend/screens/profile/VerifyEmailScreen.tsx b/frontend/screens/profile/VerifyEmailScreen.tsx new file mode 100644 index 0000000..442ec55 --- /dev/null +++ b/frontend/screens/profile/VerifyEmailScreen.tsx @@ -0,0 +1,225 @@ +import { + View, + Text, + TextInput, + Pressable, + StyleSheet, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useEffect, useState } from 'react'; +import api from '@/lib/api'; + +export default function VerifyEmailScreen() { + const [email, setEmail] = useState(''); + const [otp, setOtp] = useState(''); + + const [otpSent, setOtpSent] = useState(false); + const [cooldown, setCooldown] = useState(0); + + const [sendingOtp, setSendingOtp] = useState(false); + const [verifyingOtp, setVerifyingOtp] = useState(false); + const [verified, setVerified] = useState(false); + + useEffect(() => { + async function fetchUser() { + try { + const res = await api.get('/user/me'); + console.log('ME RESPONSE:', res.data.email, res.data); + setEmail(res.data.user.email); + setVerified(res.data.user.isEmailVerified); + } catch (error) { + Alert.alert('Error', 'Failed to load user info'); + } + } + + fetchUser(); + }, []); + + useEffect(() => { + if (cooldown <= 0) return; + + const timer = setTimeout(() => { + setCooldown((prev) => prev - 1); + }, 1000); + + return () => clearTimeout(timer); + }, [cooldown]); + + async function handleSendOtp() { + if (cooldown > 0) return; + + setSendingOtp(true); + + try { + await api.post('/auth/send-otp-email'); + setOtpSent(true); + setCooldown(30); + Alert.alert('Success', 'OTP sent to your email'); + } catch (error: any) { + Alert.alert( + 'Error', + error?.response?.data?.message || 'Failed to send OTP', + ); + } finally { + setSendingOtp(false); + } + } + + async function handleVerifyOtp() { + if (!otp) { + Alert.alert('Error', 'Enter OTP'); + return; + } + + setVerifyingOtp(true); + + try { + await api.post('/auth/verify-otp-email', { otp }); + setVerified(true); + setOtp(''); + Alert.alert('Success', 'Email verified successfully'); + } catch (error: any) { + Alert.alert( + 'Error', + error?.response?.data?.message || 'Verification failed', + ); + } finally { + setVerifyingOtp(false); + } + } + + return ( + + Verify Email + + Registered Email + + {email || 'Loading...'} + + + {verified ? ( + Email Verified + ) : ( + <> + {/* SEND / RESEND BUTTON */} + 0 || sendingOtp) && styles.disabledButton, + ]} + onPress={handleSendOtp} + disabled={cooldown > 0 || sendingOtp} + > + {sendingOtp ? ( + + ) : ( + + {otpSent ? 'Resend OTP' : 'Send OTP'} + + )} + + + {/* COOLDOWN TEXT */} + {cooldown > 0 && ( + + You can resend OTP in {cooldown}s + + )} + + {/* OTP SECTION β€” ONLY AFTER SEND */} + {otpSent && ( + + Enter OTP + + + + + {verifyingOtp ? ( + + ) : ( + Confirm OTP + )} + + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + justifyContent: 'center', + backgroundColor: '#fff', + }, + title: { + fontSize: 22, + fontWeight: '600', + marginBottom: 30, + textAlign: 'center', + }, + label: { + fontSize: 14, + marginBottom: 8, + fontWeight: '500', + }, + emailBox: { + padding: 14, + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 8, + marginBottom: 20, + }, + emailText: { + fontSize: 15, + }, + verifiedText: { + textAlign: 'center', + color: 'green', + fontWeight: '600', + marginTop: 20, + }, + input: { + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 8, + padding: 14, + marginTop: 20, + marginBottom: 16, + fontSize: 15, + }, + button: { + backgroundColor: '#4f46e5', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 10, + }, + disabledButton: { + opacity: 0.5, + }, + buttonText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + timerText: { + textAlign: 'center', + marginTop: 10, + color: '#6b7280', + }, +}); diff --git a/frontend/screens/search/SearchScreen.tsx b/frontend/screens/search/SearchScreen.tsx index 125f50b..10c4d9b 100644 --- a/frontend/screens/search/SearchScreen.tsx +++ b/frontend/screens/search/SearchScreen.tsx @@ -1,6 +1,48 @@ -import { View, Text, StyleSheet, TextInput } from 'react-native'; +import api from '@/lib/api'; +import { router } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + FlatList, + Image, + Pressable, +} from 'react-native'; export default function SearchScreen() { + type EventType = { + id: number; + title: string; + category: string; + startDate: string; + location: string; + image?: { + imageUrl: string; + }[]; + }; + const [search, setSearch] = useState(''); + const [results, setResults] = useState([]); + async function handleSearch() { + try { + console.log('inside handle search'); + + const res = await api.get(`/event/search?event=${search}`); + console.log(res.data); + setResults(res.data.events); + } catch (err) { + console.log(err); + } + } + useEffect(() => { + if (search.trim().length < 2) return; + const setT = setTimeout(() => { + handleSearch(); + }, 500); + + return () => clearTimeout(setT); + }, [search]); return ( @@ -12,17 +54,43 @@ export default function SearchScreen() { placeholder="Search events, people, places…" placeholderTextColor="#6b7280" style={styles.input} + onChangeText={(text) => { + setSearch(text); + }} /> - Recent Searches - No recent searches - + + item.id.toString()} + contentContainerStyle={{ gap: 12 }} + renderItem={({ item }) => ( + router.push(`/(tabs)/events/${item.id}`)} + style={styles.card} + > + - - Results - Start typing to see results. + + {item.title} + + + \ {new Date(item.startDate).toDateString()} + + + {item.location} + + {item.category} + + + )} + /> + ); @@ -64,4 +132,45 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#6b7280', }, + card: { + backgroundColor: '#fff', + borderRadius: 12, + overflow: 'hidden', + elevation: 3, + marginTop: 10, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + }, + + image: { + width: '100%', + height: 150, + }, + + cardContent: { + padding: 12, + gap: 4, + }, + + eventTitle: { + fontSize: 16, + fontWeight: '700', + }, + + meta: { + fontSize: 13, + color: '#6b7280', + }, + + category: { + marginTop: 6, + alignSelf: 'flex-start', + backgroundColor: '#e5e7eb', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + fontSize: 12, + fontWeight: '600', + }, }); diff --git a/frontend/screens/ticket/ticketScreen.tsx b/frontend/screens/ticket/ticketScreen.tsx new file mode 100644 index 0000000..d8ea86e --- /dev/null +++ b/frontend/screens/ticket/ticketScreen.tsx @@ -0,0 +1,238 @@ +import api from '@/lib/api'; +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + Pressable, + Image, + Modal, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function MyTicketsScreen() { + const [tickets, setTickets] = useState([]); + + async function fetchTicket() { + try { + const res = await api.get('/ticket/getMyTickets'); + + if (res.data?.success) { + setTickets(res.data.tickets); + } + } catch (err) { + console.log('Failed to fetch tickets', err); + } + } + + useEffect(() => { + fetchTicket(); + }, []); + const [selectedTicket, setSelectedTicket] = useState(null); + + const renderTicket = ({ item }: { item: any }) => { + return ( + setSelectedTicket(item)}> + + + {item.event.title} + {item.event.location} + + {new Date(item.event.startDate).toLocaleString()} + + + + {item.status.toUpperCase()} + + + + + ); + }; + + return ( + + My Tickets + + item.id} + renderItem={renderTicket} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + /> + + {/* ------------------------------ */} + {/* Ticket Detail Modal */} + {/* ------------------------------ */} + + + + + {selectedTicket && ( + <> + + {selectedTicket.event.title} + + + {selectedTicket.event.location} + + + {new Date(selectedTicket.event.startDate).toLocaleString()} + + + + + + {selectedTicket.status.toUpperCase()} + + + setSelectedTicket(null)} + > + Close + + + )} + + + + + ); +} + +// ------------------------------ +// Styles +// ------------------------------ + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + paddingHorizontal: 16, + }, + + title: { + fontSize: 24, + fontWeight: '700', + marginVertical: 16, + }, + + listContent: { + paddingBottom: 40, + }, + + card: { + backgroundColor: '#f9fafb', + borderRadius: 14, + padding: 16, + marginBottom: 14, + elevation: 2, + }, + + row: { + flexDirection: 'row', + alignItems: 'center', + }, + + infoSection: { + flex: 1, + }, + + eventTitle: { + fontSize: 16, + fontWeight: '700', + marginBottom: 6, + }, + + subText: { + fontSize: 13, + color: '#6b7280', + marginBottom: 2, + }, + + status: { + marginTop: 10, + fontSize: 12, + fontWeight: '700', + alignSelf: 'flex-start', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + }, + + activeStatus: { + backgroundColor: '#dcfce7', + color: '#166534', + }, + + usedStatus: { + backgroundColor: '#e5e7eb', + color: '#374151', + }, + + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + + modalCard: { + width: '85%', + backgroundColor: '#fff', + borderRadius: 16, + padding: 20, + alignItems: 'center', + }, + + modalTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + }, + + modalSubText: { + fontSize: 13, + color: '#6b7280', + marginBottom: 4, + }, + + qrLarge: { + width: 220, + height: 220, + marginVertical: 20, + }, + + closeBtn: { + marginTop: 20, + backgroundColor: '#111827', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 10, + }, + + closeText: { + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/frontend/theme/colors.ts b/frontend/theme/colors.ts new file mode 100644 index 0000000..901c60b --- /dev/null +++ b/frontend/theme/colors.ts @@ -0,0 +1,49 @@ +export const Colors = { + light: { + primary: '#0f172a', + primaryForeground: '#ffffff', + + secondary: '#f1f5f9', + secondaryForeground: '#0f172a', + + background: '#ffffff', + text: '#0f172a', + + muted: '#f1f5f9', + mutedForeground: '#64748b', + + border: '#e5e7eb', + + red: '#ef4444', + destructiveForeground: '#ffffff', + + green: '#22c55e', + + // βœ… added + card: '#ffffff', + }, + + dark: { + primary: '#e5e7eb', + primaryForeground: '#020617', + + secondary: '#1e293b', + secondaryForeground: '#ffffff', + + background: '#020617', + text: '#e5e7eb', + + muted: '#1e293b', + mutedForeground: '#94a3b8', + + border: '#1e293b', + + red: '#ef4444', + destructiveForeground: '#ffffff', + + green: '#22c55e', + + // βœ… added + card: '#0f172a', + }, +} as const; diff --git a/frontend/theme/globals.ts b/frontend/theme/globals.ts new file mode 100644 index 0000000..be0e2f4 --- /dev/null +++ b/frontend/theme/globals.ts @@ -0,0 +1,4 @@ +export const HEIGHT = 48; +export const FONT_SIZE = 17; +export const BORDER_RADIUS = 26; +export const CORNERS = 999; diff --git a/frontend/utils/toast.ts b/frontend/utils/toast.ts new file mode 100644 index 0000000..e1153bc --- /dev/null +++ b/frontend/utils/toast.ts @@ -0,0 +1,15 @@ +import Toast from 'react-native-toast-message'; + +export const showSuccess = (message: string) => { + Toast.show({ + type: 'success', + text1: message, + }); +}; + +export const showError = (message: string) => { + Toast.show({ + type: 'error', + text1: message, + }); +}; diff --git a/frontend/utils/toastConfig.tsx b/frontend/utils/toastConfig.tsx new file mode 100644 index 0000000..6d9d9d5 --- /dev/null +++ b/frontend/utils/toastConfig.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +export const toastConfig = { + success: ({ text1 }: any) => ( + + {text1} + + ), + + error: ({ text1 }: any) => ( + + {text1} + + ), +}; + +const styles = StyleSheet.create({ + toast: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 10, + marginHorizontal: 16, + marginTop: 10, + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 6, + elevation: 4, + }, + success: { + backgroundColor: '#22c55e', + }, + error: { + backgroundColor: '#ef4444', + }, + text: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 181ba95..81af5dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.2.1 + version: 8.2.1(express@5.2.1) form-data: specifier: ^4.0.5 version: 4.0.5 @@ -68,6 +71,18 @@ importers: pino-http: specifier: ^11.0.0 version: 11.0.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + rate-limit-redis: + specifier: ^4.3.1 + version: 4.3.1(express-rate-limit@8.2.1(express@5.2.1)) + razorpay: + specifier: ^2.9.6 + version: 2.9.6 + redis: + specifier: ^5.10.0 + version: 5.10.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -76,7 +91,10 @@ importers: version: 5.11.1 typeorm: specifier: ^0.3.28 - version: 0.3.28(pg@8.16.3)(ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3)) + version: 0.3.28(pg@8.16.3)(redis@5.10.0)(ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3)) + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@commitlint/cli': specifier: ^20.2.0 @@ -111,6 +129,9 @@ importers: '@types/pg': specifier: ^8.16.0 version: 8.16.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@typescript-eslint/eslint-plugin': specifier: ^8.51.0 version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -141,6 +162,9 @@ importers: '@react-native-async-storage/async-storage': specifier: 2.2.0 version: 2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) + '@react-native-community/datetimepicker': + specifier: 8.4.4 + version: 8.4.4(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/bottom-tabs': specifier: ^7.4.0 version: 7.9.0(@react-navigation/native@7.1.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -156,6 +180,12 @@ importers: expo: specifier: ~54.0.30 version: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-barcode-scanner: + specifier: ^13.0.1 + version: 13.0.1(expo@54.0.30) + expo-camera: + specifier: ~17.0.10 + version: 17.0.10(expo@54.0.30)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.12 version: 18.0.12(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) @@ -168,9 +198,15 @@ importers: expo-image: specifier: ~3.0.11 version: 3.0.11(expo@54.0.30)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-image-picker: + specifier: ~17.0.10 + version: 17.0.10(expo@54.0.30) expo-linking: specifier: ~8.0.11 version: 8.0.11(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-media-library: + specifier: ^18.2.1 + version: 18.2.1(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)) expo-router: specifier: ~6.0.21 version: 6.0.21(@expo/metro-runtime@6.1.2)(@types/react@19.1.17)(expo-constants@18.0.12)(expo-linking@8.0.11)(expo@54.0.30)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -195,6 +231,9 @@ importers: lucide-react-native: specifier: ^0.562.0 version: 0.562.0(react-native-svg@15.15.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 19.1.0 version: 19.1.0 @@ -207,15 +246,27 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-razorpay: + specifier: ^2.3.1 + version: 2.3.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: - specifier: ~4.1.1 + specifier: ~4.1.6 version: 4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-reanimated-carousel: + specifier: ^4.0.3 + version: 4.0.3(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-safe-area-context: - specifier: ~5.6.0 + specifier: ~5.6.2 version: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-screens: specifier: ~4.16.0 version: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: + specifier: ^15.15.1 + version: 15.15.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-toast-message: + specifier: ^2.3.3 + version: 2.3.3(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -223,6 +274,9 @@ importers: specifier: 0.5.1 version: 0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) devDependencies: + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ~19.1.0 version: 19.1.17 @@ -2217,6 +2271,22 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/datetimepicker@8.4.4': + resolution: + { + integrity: sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==, + } + peerDependencies: + expo: '>=52.0.0' + react: '*' + react-native: '*' + react-native-windows: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-windows: + optional: true + '@react-native/assets-registry@0.81.5': resolution: { @@ -2380,6 +2450,49 @@ packages: integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==, } + '@redis/bloom@5.10.0': + resolution: + { + integrity: sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==, + } + engines: { node: '>= 18' } + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/client@5.10.0': + resolution: + { + integrity: sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==, + } + engines: { node: '>= 18' } + + '@redis/json@5.10.0': + resolution: + { + integrity: sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==, + } + engines: { node: '>= 18' } + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/search@5.10.0': + resolution: + { + integrity: sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==, + } + engines: { node: '>= 18' } + peerDependencies: + '@redis/client': ^5.10.0 + + '@redis/time-series@5.10.0': + resolution: + { + integrity: sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==, + } + engines: { node: '>= 18' } + peerDependencies: + '@redis/client': ^5.10.0 + '@rtsao/scc@1.1.0': resolution: { @@ -2974,6 +3087,12 @@ packages: integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==, } + '@types/qrcode@1.5.6': + resolution: + { + integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==, + } + '@types/qs@6.14.0': resolution: { @@ -4110,6 +4229,12 @@ packages: integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, } + cliui@6.0.0: + resolution: + { + integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==, + } + cliui@8.0.1: resolution: { @@ -4124,6 +4249,13 @@ packages: } engines: { node: '>=0.8' } + cluster-key-slot@1.1.2: + resolution: + { + integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==, + } + engines: { node: '>=0.10.0' } + color-convert@1.9.3: resolution: { @@ -4466,6 +4598,13 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: + { + integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, + } + engines: { node: '>=0.10.0' } + decode-uri-component@0.2.2: resolution: { @@ -4572,6 +4711,12 @@ packages: } engines: { node: '>=0.3.1' } + dijkstrajs@1.0.3: + resolution: + { + integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==, + } + doctrine@2.1.0: resolution: { @@ -5032,6 +5177,28 @@ packages: react: '*' react-native: '*' + expo-barcode-scanner@13.0.1: + resolution: + { + integrity: sha512-xBGLT1An2gpAMIQRTLU3oHydKohX8r8F9/ait1Fk9Vgd0GraFZbP4IiT7nHMlaw4H6E7Muucf7vXpGV6u7d4HQ==, + } + peerDependencies: + expo: '*' + + expo-camera@17.0.10: + resolution: + { + integrity: sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==, + } + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-constants@18.0.12: resolution: { @@ -5068,6 +5235,30 @@ packages: peerDependencies: expo: '*' + expo-image-loader@4.7.0: + resolution: + { + integrity: sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==, + } + peerDependencies: + expo: '*' + + expo-image-loader@6.0.0: + resolution: + { + integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==, + } + peerDependencies: + expo: '*' + + expo-image-picker@17.0.10: + resolution: + { + integrity: sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==, + } + peerDependencies: + expo: '*' + expo-image@3.0.11: resolution: { @@ -5100,6 +5291,15 @@ packages: react: '*' react-native: '*' + expo-media-library@18.2.1: + resolution: + { + integrity: sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==, + } + peerDependencies: + expo: '*' + react-native: '*' + expo-modules-autolinking@3.0.23: resolution: { @@ -5242,6 +5442,15 @@ packages: integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==, } + express-rate-limit@8.2.1: + resolution: + { + integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==, + } + engines: { node: '>= 16' } + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: { @@ -5890,6 +6099,13 @@ packages: integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==, } + ip-address@10.0.1: + resolution: + { + integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==, + } + engines: { node: '>= 12' } + ipaddr.js@1.9.1: resolution: { @@ -7559,6 +7775,13 @@ packages: } engines: { node: '>=4.0.0' } + pngjs@5.0.0: + resolution: + { + integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==, + } + engines: { node: '>=10.13.0' } + possible-typed-array-names@1.1.0: resolution: { @@ -7714,6 +7937,14 @@ packages: } hasBin: true + qrcode@1.5.4: + resolution: + { + integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==, + } + engines: { node: '>=10.13.0' } + hasBin: true + qs@6.14.0: resolution: { @@ -7753,6 +7984,15 @@ packages: } engines: { node: '>= 0.6' } + rate-limit-redis@4.3.1: + resolution: + { + integrity: sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==, + } + engines: { node: '>= 16' } + peerDependencies: + express-rate-limit: '>= 6' + raw-body@3.0.2: resolution: { @@ -7760,6 +8000,12 @@ packages: } engines: { node: '>= 0.10' } + razorpay@2.9.6: + resolution: + { + integrity: sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==, + } + rc@1.2.8: resolution: { @@ -7832,6 +8078,26 @@ packages: react: '*' react-native: '*' + react-native-razorpay@2.3.1: + resolution: + { + integrity: sha512-aEod2YigiWx9Vik+2YRpTh9kNKK9KZbfhrRpk5tU8z8ZPDdLt57rRsqd7lVuDybqqO6nLY6ughPjMN+FPyX8Ag==, + } + peerDependencies: + react: '>=16.8.0' + react-native: '>=0.66.0' + + react-native-reanimated-carousel@4.0.3: + resolution: + { + integrity: sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw==, + } + peerDependencies: + react: '>=18.0.0' + react-native: '>=0.70.3' + react-native-gesture-handler: '>=2.9.0' + react-native-reanimated: '>=3.0.0' + react-native-reanimated@4.1.6: resolution: { @@ -7870,6 +8136,15 @@ packages: react: '*' react-native: '*' + react-native-toast-message@2.3.3: + resolution: + { + integrity: sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==, + } + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.21.2: resolution: { @@ -7977,6 +8252,13 @@ packages: } engines: { node: '>= 12.13.0' } + redis@5.10.0: + resolution: + { + integrity: sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==, + } + engines: { node: '>= 18' } + reflect-metadata@0.2.2: resolution: { @@ -8050,6 +8332,12 @@ packages: } engines: { node: '>=0.10.0' } + require-main-filename@2.0.0: + resolution: + { + integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==, + } + requireg@0.2.2: resolution: { @@ -8291,6 +8579,12 @@ packages: integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==, } + set-blocking@2.0.0: + resolution: + { + integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, + } + set-function-length@1.2.2: resolution: { @@ -9253,6 +9547,13 @@ packages: } hasBin: true + uuid@13.0.0: + resolution: + { + integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==, + } + hasBin: true + uuid@7.0.3: resolution: { @@ -9366,6 +9667,12 @@ packages: } engines: { node: '>= 0.4' } + which-module@2.0.1: + resolution: + { + integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==, + } + which-typed-array@1.1.19: resolution: { @@ -9394,6 +9701,13 @@ packages: } engines: { node: '>=0.10.0' } + wrap-ansi@6.2.0: + resolution: + { + integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==, + } + engines: { node: '>=8' } + wrap-ansi@7.0.0: resolution: { @@ -9507,6 +9821,12 @@ packages: } engines: { node: '>=0.4' } + y18n@4.0.3: + resolution: + { + integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==, + } + y18n@5.0.8: resolution: { @@ -9535,6 +9855,13 @@ packages: engines: { node: '>= 14.6' } hasBin: true + yargs-parser@18.1.3: + resolution: + { + integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==, + } + engines: { node: '>=6' } + yargs-parser@21.1.1: resolution: { @@ -9542,6 +9869,13 @@ packages: } engines: { node: '>=12' } + yargs@15.4.1: + resolution: + { + integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==, + } + engines: { node: '>=8' } + yargs@17.7.2: resolution: { @@ -11490,6 +11824,14 @@ snapshots: merge-options: 3.0.4 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + '@react-native-community/datetimepicker@8.4.4(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.28.5)': @@ -11674,6 +12016,26 @@ snapshots: dependencies: nanoid: 3.3.11 + '@redis/bloom@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/client@5.10.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/search@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + + '@redis/time-series@5.10.0(@redis/client@5.10.0)': + dependencies: + '@redis/client': 5.10.0 + '@rtsao/scc@1.1.0': {} '@sinclair/typebox@0.27.8': {} @@ -12143,6 +12505,10 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.0.3 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -12936,6 +13302,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -12944,6 +13316,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -13142,6 +13516,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-uri-component@0.2.2: {} dedent@1.7.1: {} @@ -13182,6 +13558,8 @@ snapshots: diff@4.0.2: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -13380,7 +13758,7 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) @@ -13400,7 +13778,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -13415,14 +13793,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -13446,7 +13824,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13576,6 +13954,20 @@ snapshots: transitivePeerDependencies: - supports-color + expo-barcode-scanner@13.0.1(expo@54.0.30): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-image-loader: 4.7.0(expo@54.0.30) + + expo-camera@17.0.10(expo@54.0.30)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + expo-constants@18.0.12(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -13601,6 +13993,19 @@ snapshots: dependencies: expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-image-loader@4.7.0(expo@54.0.30): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + expo-image-loader@6.0.0(expo@54.0.30): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + expo-image-picker@17.0.10(expo@54.0.30): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-image-loader: 6.0.0(expo@54.0.30) + expo-image@3.0.11(expo@54.0.30)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -13624,6 +14029,11 @@ snapshots: - expo - supports-color + expo-media-library@18.2.1(expo@54.0.30)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + expo-modules-autolinking@3.0.23: dependencies: '@expo/spawn-async': 1.7.2 @@ -13759,6 +14169,11 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -14154,6 +14569,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -15199,6 +15616,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} @@ -15272,6 +15691,12 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -15293,6 +15718,10 @@ snapshots: range-parser@1.2.1: {} + rate-limit-redis@4.3.1(express-rate-limit@8.2.1(express@5.2.1)): + dependencies: + express-rate-limit: 8.2.1(express@5.2.1) + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -15300,6 +15729,12 @@ snapshots: iconv-lite: 0.7.1 unpipe: 1.0.0 + razorpay@2.9.6: + dependencies: + axios: 1.13.2 + transitivePeerDependencies: + - debug + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -15345,6 +15780,18 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-razorpay@2.3.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + + react-native-reanimated-carousel@4.0.3(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-reanimated: 4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: '@babel/core': 7.28.5 @@ -15375,6 +15822,11 @@ snapshots: react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) warn-once: 0.1.1 + react-native-toast-message@2.3.3(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 @@ -15499,6 +15951,14 @@ snapshots: real-require@0.2.0: {} + redis@5.10.0: + dependencies: + '@redis/bloom': 5.10.0(@redis/client@5.10.0) + '@redis/client': 5.10.0 + '@redis/json': 5.10.0(@redis/client@5.10.0) + '@redis/search': 5.10.0(@redis/client@5.10.0) + '@redis/time-series': 5.10.0(@redis/client@5.10.0) + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -15548,6 +16008,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireg@0.2.2: dependencies: nested-error-stacks: 2.0.1 @@ -15706,6 +16168,8 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -16161,7 +16625,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.28(pg@8.16.3)(ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3)): + typeorm@0.3.28(pg@8.16.3)(redis@5.10.0)(ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.2.0 @@ -16180,6 +16644,7 @@ snapshots: yargs: 17.7.2 optionalDependencies: pg: 8.16.3 + redis: 5.10.0 ts-node: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros @@ -16300,6 +16765,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@7.0.3: {} v8-compile-cache-lib@3.0.1: {} @@ -16377,6 +16844,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -16395,6 +16864,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -16440,6 +16915,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -16448,8 +16925,27 @@ snapshots: yaml@2.8.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/worker/package.json b/worker/package.json deleted file mode 100644 index a78d91a..0000000 --- a/worker/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "worker", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "packageManager": "pnpm@10.26.2" -}