Compare commits

...

41 commits

Author SHA1 Message Date
AeThex
1a4321a531 fix: await Discord SDK voice subscribes and add missing Activity API endpoints
Some checks failed
Build / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Lint & Type Check / lint (push) Has been cancelled
Security Scan / semgrep (push) Has been cancelled
Security Scan / dependency-check (push) Has been cancelled
Test / test (18.x) (push) Has been cancelled
Test / test (20.x) (push) Has been cancelled
- Await SPEAKING_START/SPEAKING_STOP subscribes so try-catch handles 4006 scope errors
- Add GET /api/feed and POST /api/feed/:id/like (community posts alias for Activity)
- Add DELETE /api/activity/polls/:id (soft-delete via is_active=false)
- Add POST /api/activity/challenges/:id/claim (marks challenge progress as completed)
- Add GET /api/activity/badges (all badges, no userId required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 02:24:32 +00:00
AeThex
be30e01a50 fix: remove .catch() from Supabase channel.subscribe() calls
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
RealtimeChannel.subscribe() returns the channel object, not a Promise.
Calling .catch() on it throws at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 02:10:22 +00:00
AeThex
dbd980a6ec fix: pass VITE_* env vars as Docker build args for client bundle
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
VITE_* variables are baked into the bundle at vite build time. Docker's
env_file only applies at runtime, so they were missing from the build.
Pass them as ARGs from docker-compose so the client bundle includes the
correct Supabase URL and anon key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 02:04:25 +00:00
AeThex
a68a2b9f8e fix: build client in Docker and serve compiled SPA from Express
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
Vite dev mode is incompatible with Discord Activity's iframe CSP — the
HMR WebSocket is blocked which breaks the React JSX dev runtime (_jsxDEV).

- Build client (vite build) during Docker image build so dist/spa/ exists
- Add express.static serving dist/spa/ assets in server/index.ts
- Add SPA catch-all to serve dist/spa/index.html for non-API routes

The Activity now loads the production compiled bundle instead of Vite's
dev-mode TypeScript modules, resolving the _jsxDEV crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:59:56 +00:00
AeThex
0b1d7a9441 fix: lazy-load Downloads page to prevent module-level JSX crash in Activity
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
Downloads.tsx has JSX at module initialization (icon fields in const array).
Eagerly importing it crashes the entire app in the Discord Activity iframe
before the React JSX runtime is ready. Lazy loading defers evaluation until
the route is actually navigated to.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:40:43 +00:00
AeThex
446ad7159c fix: include server/ in Docker image and fix Vite ssrLoadModule path
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
- Remove 'server' from .dockerignore so the Express server is baked into
  the image (was being excluded, causing 'Cannot find module' on startup)
- Switch from import("./server") to server.ssrLoadModule("/server/index.ts")
  so Vite resolves the path from the project root, not the temp compile dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:36:35 +00:00
AeThex
885ea76d12 fix: use ssrLoadModule to load Express server in Vite dev plugin
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
Dynamic import("./server") resolves relative to Vite's .vite-temp/
compilation dir, not the project root. ssrLoadModule resolves from
/app root and processes TypeScript correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:28:32 +00:00
AeThex
a57cdb029a chore: remove vercel.json — site now served from VPS Docker container
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:54:54 +00:00
AeThex
f1bcc957f9 fix: Discord Activity token exchange, CSP headers, subscription routes, and static asset 404
- Remove redirect_uri from Discord token exchange (Activities use proxy auth, not redirect flow)
- Add Content-Security-Policy with frame-ancestors for Discord embedding (was only in vercel.json)
- Wire up subscription create-checkout and manage routes in Express
- Add Studio arm to ArmSwitcher with external link
- Prevent SPA catch-all from serving HTML for missing static assets (fixes script.js Unexpected token error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:49:50 +00:00
AeThex
34368e1dde fix: server-side OG/Twitter meta injection for crawler visibility
Some checks failed
Security Scan / semgrep (push) Has been cancelled
Security Scan / dependency-check (push) Has been cancelled
Build / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Lint & Type Check / lint (push) Has been cancelled
Test / test (18.x) (push) Has been cancelled
Test / test (20.x) (push) Has been cancelled
Crawlers (Twitter, Discord, Slack) don't execute JavaScript, so the
client-side SEO.tsx useEffect was invisible to them. Every page looked
identical — the hardcoded homepage defaults in index.html.

- node-build.ts: replace simple sendFile with async SSR meta middleware
  that injects per-route title/description/og:*/twitter:* before sending
  HTML. Static route map covers ~15 routes; dynamic lookup queries
  Supabase for /projects/:uuid (title, description, image_url) and
  /passport/:username (full_name, bio) so shared project/profile links
  render correct cards in Discord/Twitter/Slack unfurls.
- index.html: add twitter:site @aethexcorp; SSO.tsx useEffect still
  runs for browser tab updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:30:47 +00:00
AeThex
29a32da48a feat: make staff.aethex.tech work as staff section alias
- Add StaffSubdomainRedirect component: when accessed via staff.aethex.tech,
  auto-redirects to /staff prefix so the subdomain serves the right content
- nginx config updated to proxy to aethex-forge (port 5050) instead of
  dead port 5054

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 08:29:20 +00:00
AeThex
fbc5ed2f40 fix: restore staff & candidate routes — remove dead staff.aethex.tech redirects
staff.aethex.tech doesn't exist. Re-wired /staff and /candidate to the
existing page components already in the codebase:

Staff: Staff, StaffLogin, StaffDashboard, StaffAdmin, StaffChat,
       StaffDocs, StaffDirectory, StaffAchievements, StaffTimeTracking
Candidate: CandidatePortal, CandidateInterviews, CandidateOffers,
           CandidateProfile

Unbuilt staff sub-routes (/staff/announcements etc.) now fall back to
/staff/dashboard instead of 404ing through a dead external domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 08:26:12 +00:00
AeThex
1599d0e690 fix: prevent false session logouts and wire up remember-me
- Narrow the unhandledrejection error handler: removed "unauthorized"
  and "auth/" patterns which were too broad and cleared sessions on
  unrelated API 401s or any URL containing "auth/". Now only matches
  specific Supabase strings (invalid refresh token, jwt expired, etc.)
- Wire up the Remember Me checkbox in Login — was purely decorative
  before. Defaults to checked, stores aethex_remember_me in localStorage
- Authentik SSO callback now sets a 30-day cookie so SSO sessions
  survive browser restarts (AuthContext promotes it to localStorage)
- AuthContext clears local session on load if remember-me flag is absent
  (respects user's choice to not stay logged in)
- signOut now removes aethex_remember_me from localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 08:15:47 +00:00
AeThex
7fec93e05c feat: Authentik SSO, nav systems, project pages, and schema fixes
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
Auth & SSO
- Wire Authentik (auth.aethex.tech) as OIDC PKCE SSO provider
- Server-side only flow with HMAC-signed stateless state token
- Account linking via authentik_sub in user metadata
- AeThex ID connection card in Dashboard connections tab
- Unlink endpoint POST /api/auth/authentik/unlink
- Fix node:https helper to bypass undici DNS bug on Node 18
- Fix resolv.conf to use 1.1.1.1/8.8.8.8 in container

Schema & types
- Regenerate database.types.ts from live Supabase schema (23k lines)
- Fix 511 TypeScript errors caused by stale 582-line types file
- Fix UserProfile import in aethex-database-adapter.ts
- Add notifications migration (title, message, read columns)

Server fixes
- Remove badge_color from achievements seed/upsert (column doesn't exist)
- Rename name→title, add slug field in achievements seed
- Remove email from all user_profiles select queries (column doesn't exist)
- Fix email-based achievement target lookup via auth.admin.listUsers
- Add GET /api/projects/:projectId endpoint
- Fix import.meta.dirname → fileURLToPath for Node 18 compatibility
- Expose VITE_APP_VERSION from package.json at build time

Navigation systems
- DevPlatformNav: reorganize into Learn/Build grouped dropdowns with descriptions
- Migrate all 11 dev-platform pages from main Layout to DevPlatformLayout
- Remove dead isDevMode context nav swap from main Layout
- EthosLayout: purple-accented tab bar (Library, Artists, Licensing, Settings)
  with member-only gating and guest CTA — migrate 4 Ethos pages
- GameForgeLayout: orange-branded sidebar with Studio section and lock icons
  for unauthenticated users — migrate GameForge + GameForgeDashboard
- SysBar: live latency ping, status dot (green/yellow/red), real version

Layout dropdown
- Role-gate Admin (owner/admin/founder only) and Internal Docs (+ staff)
- Add Internal section label with separator
- Fix settings link from /dashboard?tab=profile#settings to /dashboard?tab=settings

Project pages
- Add ProjectDetail page at /projects/:projectId
- Fix ProfilePassport "View mission" link from /projects/new to /projects/:id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 05:01:10 +00:00
AeThex
06b748dade fix: move module-level JSX icons inside component to resolve _jsxDEV error
Some checks failed
Build / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Lint & Type Check / lint (push) Has been cancelled
Security Scan / semgrep (push) Has been cancelled
Security Scan / dependency-check (push) Has been cancelled
Test / test (18.x) (push) Has been cancelled
Test / test (20.x) (push) Has been cancelled
2026-04-08 02:02:39 +00:00
AeThex
c67ee049b6 fix: resolve broken auth imports and JSX tag mismatches
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
- Fix 14 files importing useAuth from nonexistent @/lib/auth or @/hooks/useAuth → @/contexts/AuthContext
- Fix ClientReports: Button wrapping Card content, add proper Tabs/TabsContent structure
- Fix ClientInvoices, ClientContracts: </div> → </section> tag mismatch
- Fix ClientSettings: orphaned </TabsContent>, add missing Tabs wrapper and profile tab
- Re-enable 12 disabled pages in App.tsx (hub + staff routes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:39:46 +00:00
root
58c1f539b9 fix: disable broken pages, fix Foundation duplicate import and tag mismatch
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
2026-04-07 06:20:52 +00:00
f4813e7d9b
Merge pull request #2 from AeThex-Corporation/claude/find-unfinished-flows-vKjsD
Some checks failed
Build / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Lint & Type Check / lint (push) Has been cancelled
Security Scan / semgrep (push) Has been cancelled
Security Scan / dependency-check (push) Has been cancelled
Test / test (18.x) (push) Has been cancelled
Test / test (20.x) (push) Has been cancelled
Claude/find unfinished flows v kjs d
2026-01-26 15:50:51 -07:00
MrPiglr
f2823e2cd1
Merge branch 'main' into claude/find-unfinished-flows-vKjsD 2026-01-26 15:50:36 -07:00
Claude
b640b0d2ad
Mobile optimization pass for responsive layouts
- TabsList: Add responsive grid columns (grid-cols-2/3 on mobile)
- Headers: Stack vertically on mobile with responsive text sizes
- Dialogs: Use viewport-relative heights (70-80vh on mobile)
- Grids: Add sm: breakpoints for single-column mobile layouts
- Tables: Add overflow-x-auto for horizontal scrolling
- Buttons: Full-width on mobile with flex-1 sm:flex-none
- Select triggers: Full-width on mobile

Files updated: 21 component and page files across admin,
staff, dashboards, and hub sections.
2026-01-26 22:46:26 +00:00
Claude
88e364f4c5
Add admin moderation and analytics dashboards
Moderation Dashboard:
- API with admin-only access for content moderation
- View/filter reports by status (open, resolved, ignored)
- View/resolve contract disputes
- Manage flagged users (warn, ban, unban)
- Resolution notes for audit trail
- Stats for open reports, disputes, resolved today

Analytics Dashboard:
- Comprehensive platform metrics API
- User stats: total, new, active, creators
- Opportunity and application metrics
- Contract tracking and completion rates
- Revenue tracking by period
- Daily signup trend visualization
- Top performing opportunities ranking
- Period selector (7, 30, 90, 365 days)

Both dashboards have proper admin authorization checks.
2026-01-26 22:39:47 +00:00
Claude
ebf62ec80e
Add OKR management and time tracking features
OKR Management:
- Database tables for OKRs, key results, and check-ins
- Full CRUD API with progress auto-calculation
- UI with quarter/year filtering, create/edit dialogs
- Key result progress tracking with status indicators
- Trigger to auto-update OKR progress from key results

Time Tracking:
- Database tables for time entries and timesheets
- API with timer start/stop, manual entry creation
- Week/month/all view with grouped entries by date
- Stats for total hours, billable hours, avg per day
- Real-time timer with running indicator

Both features include RLS policies and proper indexes.
2026-01-26 22:36:16 +00:00
Claude
01026d43cc
Wire remaining 6 staff pages to real APIs
- StaffLearningPortal: Fetches courses from /api/staff/courses, supports
  starting courses and tracking progress
- StaffPerformanceReviews: Fetches reviews from /api/staff/reviews,
  supports adding employee comments with dialog
- StaffKnowledgeBase: Fetches articles from /api/staff/knowledge-base,
  supports search, filtering, view tracking, and helpful marking
- StaffProjectTracking: Fetches projects from /api/staff/projects,
  supports task status updates and creating new tasks
- StaffInternalMarketplace: Now points marketplace with /api/staff/marketplace,
  supports redeeming items with points and viewing orders
- StaffTeamHandbook: Fetches handbook sections from /api/staff/handbook,
  displays grouped by category with expand/collapse

All pages now use real Supabase data instead of mock arrays.
2026-01-26 22:31:35 +00:00
Claude
f1efc97c86
Add staff feature APIs and update 2 pages to use real data
- Add database migration for staff features (announcements, expenses,
  courses, reviews, knowledge base, projects, marketplace, handbook)
- Add 8 new API endpoints: announcements, expenses, courses, reviews,
  knowledge-base, projects, marketplace, handbook
- Update StaffAnnouncements.tsx to use real API with read tracking
- Update StaffExpenseReports.tsx to use real API with submit dialog

More staff pages to be updated in next commit.
2026-01-26 22:25:14 +00:00
Claude
61fb02cd39
Update Foundation page to informational redirect
Foundation content now lives at aethex.foundation. This page:
- Shows brief informational overview
- Auto-redirects to aethex.foundation after 10 seconds
- Provides quick links to GameForge, Mentorship, Community, and Axiom
- Maintains Foundation branding while directing users to new home
2026-01-26 22:02:20 +00:00
Claude
1a2a9af335
Complete Candidate Portal with all pages and routes
- Add CandidateInterviews.tsx with interview list, filtering by status,
  meeting type badges, and join meeting buttons
- Add CandidateOffers.tsx with offer management, accept/decline dialogs,
  expiry warnings, and salary formatting
- Add routes to App.tsx for /candidate, /candidate/profile,
  /candidate/interviews, /candidate/offers
2026-01-26 22:01:13 +00:00
Claude
0674a282b0
Add Candidate Portal foundation - API and core pages
- Add database migration for candidate_profiles, candidate_interviews,
  and candidate_offers tables with RLS policies
- Add API endpoints: /api/candidate/profile, /api/candidate/interviews,
  /api/candidate/offers
- Add CandidatePortal.tsx main dashboard with stats, quick actions,
  upcoming interviews, and pending offers
- Add CandidateProfile.tsx profile builder with tabs for basic info,
  work experience, education, and portfolio links
2026-01-26 21:58:45 +00:00
Claude
0136d3d8a4
Build complete Staff Onboarding Portal
- Add StaffOnboarding.tsx main hub with welcome banner, progress ring,
  and quick action cards
- Add StaffOnboardingChecklist.tsx with interactive Day 1/Week 1/Month 1
  checklist that saves progress to database
- Add database migration for staff_onboarding_progress and
  staff_onboarding_metadata tables with RLS policies
- Add API endpoint /api/staff/onboarding for fetching and updating
  onboarding progress with admin view for managers
- Add routes to App.tsx for /staff/onboarding and /staff/onboarding/checklist
2026-01-26 21:14:44 +00:00
Claude
9c3942ebbc
Build complete Client Portal pages
Replaced 4 placeholder pages with full implementations:

- ClientContracts.tsx (455 lines)
  - Contract list with search/filter
  - Contract detail view with milestones
  - Document management
  - Amendment history
  - Status tracking (draft/active/completed/expired)

- ClientInvoices.tsx (456 lines)
  - Invoice list with status filters
  - Invoice detail with line items
  - Payment processing (Pay Now)
  - PDF download
  - Billing stats dashboard

- ClientReports.tsx (500 lines)
  - Project reports with analytics
  - Budget analysis by project
  - Time tracking summaries
  - Export to PDF/CSV
  - 4 tab views (overview/projects/budget/time)

- ClientSettings.tsx (695 lines)
  - Company profile management
  - Team member invites/management
  - Notification preferences
  - Billing settings
  - Security settings (2FA, password, danger zone)

All pages match ClientHub styling and use existing APIs.
2026-01-26 21:10:00 +00:00
MrPiglr
0623521374
feat: complete Phase 2 design system rollout
Applied max-w-6xl standard across all remaining pages:

**Internal Tools & Admin (6 files):**
- Teams.tsx, Squads.tsx, Network.tsx, Portal.tsx
- Admin.tsx, BotPanel.tsx, Arms.tsx

**Hub/Client Pages (6 files):**
- ClientHub.tsx, ClientProjects.tsx (all 3 instances)
- ClientDashboard.tsx, ClientSettings.tsx
- ClientContracts.tsx, ClientInvoices.tsx, ClientReports.tsx

**Dashboard Pages (5 files):**
- FoundationDashboard.tsx, GameForgeDashboard.tsx
- StaffDashboard.tsx, NexusDashboard.tsx, LabsDashboard.tsx

**Community & Creator Pages (6 files):**
- Directory.tsx, Projects.tsx
- CreatorDirectory.tsx, MentorProfile.tsx, EthosGuild.tsx
- FoundationDownloadCenter.tsx, OpportunitiesHub.tsx

**Result:** Zero instances of max-w-7xl remaining in client/pages
All pages now use consistent max-w-6xl width for optimal readability
2026-01-11 01:57:16 +00:00
MrPiglr
f8c3027428
refactor: standardize content widths on GameForge, Dashboard, and Blog pages
- Reduced all max-w-7xl containers to max-w-6xl for better readability
- Applied design system standards to GameForge (hero, stats, features, team sections)
- Updated Dashboard main container width
- Normalized Blog page section widths (filter, insights, posts, newsletter)
- Improved visual consistency across high-traffic pages
2026-01-11 01:49:59 +00:00
MrPiglr
374f239e3d
feat: implement design system with consistent layout tokens
- Created design-tokens.ts with standardized width, typography, spacing, padding, and grid constants
- Reduced max-w-7xl to max-w-6xl across homepage and major landing pages (Index, Labs, Realms, Foundation)
- Reduced oversized hero text-7xl to text-6xl for better readability
- Applied consistent design standards to dev platform pages (Templates, Marketplace, CodeExamples, DevLanding)
- Improved visual consistency and content readability site-wide
2026-01-11 01:42:19 +00:00
MrPiglr
4e1f1f8cb7
fix: improve docs curriculum layout spacing and sizing 2026-01-11 01:15:50 +00:00
MrPiglr
2e77ae6982
feat: add device linking page at /link 2026-01-11 00:37:44 +00:00
MrPiglr
5cd59f1333
docs: update device linking URLs to use dashboard endpoint 2026-01-11 00:36:18 +00:00
MrPiglr
f9ef7e0298
Merge branch 'feat/database-migration-and-dev-platform' 2026-01-11 00:28:21 +00:00
MrPiglr
d9b3fc625e
Merge pull request #4 from AeThex-Corporation/feat/database-migration-and-dev-platform
feat: Improve developer platform navigation and discoverability
2026-01-09 19:35:29 -07:00
MrPiglr
897e78a1a3
Merge pull request #3 from AeThex-Corporation/feat/database-migration-and-dev-platform
feat: Complete database migration and developer platform
2026-01-09 19:09:00 -07:00
Claude
0953628bf5
Add complete line-by-line build status review
Comprehensive analysis of entire codebase:
- 161 client pages (95.7% complete)
- 134 API endpoints (37% complete, 57% stubs)
- 69 backend files (99% complete)
- 48 database migrations (100% complete)

Key gaps identified:
- 4 client portal placeholder pages
- 76 API endpoint stubs (GameForge, Labs, Foundation, etc.)
- 1 TODO in watcher service
2026-01-03 20:15:15 +00:00
Claude
db37bfc733
Add complete codebase audit of all incomplete items
Comprehensive scan identifying:
- 2 CRITICAL blocking issues (Discord CSP, SDK auth)
- 7 HIGH priority unfinished features
- 8 placeholder/stub pages
- 49+ any type usages
- 150+ debug console.log statements
- 4 TODO comments
- 5 mock data instances
- 10+ "Coming Soon" UI elements

Includes prioritized fix order and complete/incomplete summary.
2026-01-03 19:48:53 +00:00
Claude
f29196363f
Add comprehensive flow status inventory
Document all 53 flows in the codebase with completion status:
- 46 complete flows
- 6 partial/unfinished flows
- 1 not implemented flow

Key unfinished items identified:
- Discord Activity CSP blocking (P1)
- Discord SDK authentication missing (P2)
- Email verification not implemented (P3)
- Mentorship UI incomplete (P4)
- Creator Network needs Nexus features (P5)
- Client Portal not built (P6)
2026-01-03 19:38:13 +00:00
137 changed files with 40391 additions and 5010 deletions

View file

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

View file

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

187
api/admin/analytics.ts Normal file
View file

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

245
api/admin/moderation.ts Normal file
View file

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

196
api/candidate/interviews.ts Normal file
View file

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

136
api/candidate/offers.ts Normal file
View file

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

191
api/candidate/profile.ts Normal file
View file

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

View file

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

View file

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

100
api/staff/courses.ts Normal file
View file

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

96
api/staff/expenses.ts Normal file
View file

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

46
api/staff/handbook.ts Normal file
View file

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

View file

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

126
api/staff/marketplace.ts Normal file
View file

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

View file

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

289
api/staff/onboarding.ts Normal file
View file

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

102
api/staff/projects.ts Normal file
View file

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

60
api/staff/reviews.ts Normal file
View file

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

245
api/staff/time-tracking.ts Normal file
View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

30
client/lib/auth-fetch.ts Normal file
View file

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

File diff suppressed because it is too large Load diff

101
client/lib/design-tokens.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

131
client/pages/Link.tsx Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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