From 314c5b43b5a66a1bc6821e7411823a0bd137a43a Mon Sep 17 00:00:00 2001 From: "m@" <35651510+BlockSavvy@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:10:48 -0500 Subject: [PATCH 001/215] chore: organize codebase and fix JetSeatVisualizer API - Archive SQL migrations, fix scripts, organize docs, fix API issues, consolidate utils --- app/api/jets/[id]/route.ts | 18 +++- .../fix-scripts/fix-crew-db.js | 0 .../fix-scripts/fix-status-constraint.js | 0 .../fix-scripts/run-add-missing-columns.js | 0 cookies.txt => archive/logs/cookies.txt | 0 .../logs/supabase_logs.csv | 0 .../Screen Shot 2025-03-31 at 6.06.36 PM.png | Bin .../Screen Shot 2025-03-31 at 6.10.15 PM.png | Bin .../sql/add_missing_columns_to_jets.sql | 0 .../sql/concierge_voice_tables.sql | 0 .../sql/data-exports/aircraft_models_rows.sql | 0 .../sql/data-exports/jet_interiors_rows.sql | 0 .../sql/data-exports/jets_rows.sql | 0 .../sql/fix-offers-status.sql | 0 .../sql/jetshare_seed.sql | 0 .../sql/migration_embedding_logs.sql | 0 .../sql/migration_fix_database_admin.sql | 0 .../sql/migration_fix_embedding_logs.sql | 0 .../sql/migration_fix_run_sql.sql | 0 seed-jets.sql => archive/sql/seed-jets.sql | 0 .../sql/seed_marketplace_offers.sql | 0 cleanup-report.md | 79 ++++++++++++++++++ .../feature-docs/AI-INFERENCE-SETUP-NOTES.md | 0 .../feature-docs/FIX-JETSHARE-STATUS.md | 0 .../feature-docs/FIX-STATUS-ISSUE.md | 0 .../JETSHARE-AI-CONCIERGE-DOCS.md | 0 .../feature-docs/PILOTS-CREW-FEATURE.md | 0 .../feature-docs/README-OPENAI.md | 0 .../feature-docs/README-jet-images.md | 0 .../project-docs/MIGRATION-NEXT15.md | 0 .../project-docs/PR-TEMPLATE-CONCIERGE.md | 0 lib/utils.ts | 45 +--------- 32 files changed, 98 insertions(+), 44 deletions(-) rename fix-crew-db.js => archive/fix-scripts/fix-crew-db.js (100%) rename fix-status-constraint.js => archive/fix-scripts/fix-status-constraint.js (100%) rename run-add-missing-columns.js => archive/fix-scripts/run-add-missing-columns.js (100%) rename cookies.txt => archive/logs/cookies.txt (100%) rename supabase_logs.csv => archive/logs/supabase_logs.csv (100%) rename Screen Shot 2025-03-31 at 6.06.36 PM.png => archive/screenshots/Screen Shot 2025-03-31 at 6.06.36 PM.png (100%) rename Screen Shot 2025-03-31 at 6.10.15 PM.png => archive/screenshots/Screen Shot 2025-03-31 at 6.10.15 PM.png (100%) rename add_missing_columns_to_jets.sql => archive/sql/add_missing_columns_to_jets.sql (100%) rename concierge_voice_tables.sql => archive/sql/concierge_voice_tables.sql (100%) rename aircraft_models_rows (2).sql => archive/sql/data-exports/aircraft_models_rows.sql (100%) rename jet_interiors_rows (1).sql => archive/sql/data-exports/jet_interiors_rows.sql (100%) rename jets_rows (3).sql => archive/sql/data-exports/jets_rows.sql (100%) rename fix-offers-status.sql => archive/sql/fix-offers-status.sql (100%) rename jetshare_seed.sql => archive/sql/jetshare_seed.sql (100%) rename migration_embedding_logs.sql => archive/sql/migration_embedding_logs.sql (100%) rename migration_fix_database_admin.sql => archive/sql/migration_fix_database_admin.sql (100%) rename migration_fix_embedding_logs.sql => archive/sql/migration_fix_embedding_logs.sql (100%) rename migration_fix_run_sql.sql => archive/sql/migration_fix_run_sql.sql (100%) rename seed-jets.sql => archive/sql/seed-jets.sql (100%) rename seed_marketplace_offers.sql => archive/sql/seed_marketplace_offers.sql (100%) create mode 100644 cleanup-report.md rename AI-INFERENCE-SETUP-NOTES.md => docs/feature-docs/AI-INFERENCE-SETUP-NOTES.md (100%) rename FIX-JETSHARE-STATUS.md => docs/feature-docs/FIX-JETSHARE-STATUS.md (100%) rename FIX-STATUS-ISSUE.md => docs/feature-docs/FIX-STATUS-ISSUE.md (100%) rename JETSHARE-AI-CONCIERGE-DOCS.md => docs/feature-docs/JETSHARE-AI-CONCIERGE-DOCS.md (100%) rename PILOTS-CREW-FEATURE.md => docs/feature-docs/PILOTS-CREW-FEATURE.md (100%) rename README-OPENAI.md => docs/feature-docs/README-OPENAI.md (100%) rename README-jet-images.md => docs/feature-docs/README-jet-images.md (100%) rename MIGRATION-NEXT15.md => docs/project-docs/MIGRATION-NEXT15.md (100%) rename PR-TEMPLATE-CONCIERGE.md => docs/project-docs/PR-TEMPLATE-CONCIERGE.md (100%) diff --git a/app/api/jets/[id]/route.ts b/app/api/jets/[id]/route.ts index 38920f16..5a1c1459 100644 --- a/app/api/jets/[id]/route.ts +++ b/app/api/jets/[id]/route.ts @@ -1,6 +1,6 @@ import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; import { cookies } from 'next/headers'; -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { GetRouteHandler, PostRouteHandler, PatchRouteHandler, DeleteRouteHandler, PutRouteHandler, IdParam } from '@/lib/types/route-types'; // Define interface for aircraft layout template @@ -54,7 +54,21 @@ export const GET: GetRouteHandler<{ id: string }> = async ( ) => { try { const { id } = await context.params; -const jet_id = id; + const jet_id = id; + + // Special case: If "default" is requested, return the default layout + if (jet_id === 'default') { + return NextResponse.json({ + jet: { + id: 'default', + model: 'Default Jet', + manufacturer: 'Generic', + capacity: 12 + }, + interior: null, + seatLayout: AIRCRAFT_LAYOUTS['default'] + }); + } // Initialize Supabase client const supabase = createServerComponentClient({ cookies }); diff --git a/fix-crew-db.js b/archive/fix-scripts/fix-crew-db.js similarity index 100% rename from fix-crew-db.js rename to archive/fix-scripts/fix-crew-db.js diff --git a/fix-status-constraint.js b/archive/fix-scripts/fix-status-constraint.js similarity index 100% rename from fix-status-constraint.js rename to archive/fix-scripts/fix-status-constraint.js diff --git a/run-add-missing-columns.js b/archive/fix-scripts/run-add-missing-columns.js similarity index 100% rename from run-add-missing-columns.js rename to archive/fix-scripts/run-add-missing-columns.js diff --git a/cookies.txt b/archive/logs/cookies.txt similarity index 100% rename from cookies.txt rename to archive/logs/cookies.txt diff --git a/supabase_logs.csv b/archive/logs/supabase_logs.csv similarity index 100% rename from supabase_logs.csv rename to archive/logs/supabase_logs.csv diff --git a/Screen Shot 2025-03-31 at 6.06.36 PM.png b/archive/screenshots/Screen Shot 2025-03-31 at 6.06.36 PM.png similarity index 100% rename from Screen Shot 2025-03-31 at 6.06.36 PM.png rename to archive/screenshots/Screen Shot 2025-03-31 at 6.06.36 PM.png diff --git a/Screen Shot 2025-03-31 at 6.10.15 PM.png b/archive/screenshots/Screen Shot 2025-03-31 at 6.10.15 PM.png similarity index 100% rename from Screen Shot 2025-03-31 at 6.10.15 PM.png rename to archive/screenshots/Screen Shot 2025-03-31 at 6.10.15 PM.png diff --git a/add_missing_columns_to_jets.sql b/archive/sql/add_missing_columns_to_jets.sql similarity index 100% rename from add_missing_columns_to_jets.sql rename to archive/sql/add_missing_columns_to_jets.sql diff --git a/concierge_voice_tables.sql b/archive/sql/concierge_voice_tables.sql similarity index 100% rename from concierge_voice_tables.sql rename to archive/sql/concierge_voice_tables.sql diff --git a/aircraft_models_rows (2).sql b/archive/sql/data-exports/aircraft_models_rows.sql similarity index 100% rename from aircraft_models_rows (2).sql rename to archive/sql/data-exports/aircraft_models_rows.sql diff --git a/jet_interiors_rows (1).sql b/archive/sql/data-exports/jet_interiors_rows.sql similarity index 100% rename from jet_interiors_rows (1).sql rename to archive/sql/data-exports/jet_interiors_rows.sql diff --git a/jets_rows (3).sql b/archive/sql/data-exports/jets_rows.sql similarity index 100% rename from jets_rows (3).sql rename to archive/sql/data-exports/jets_rows.sql diff --git a/fix-offers-status.sql b/archive/sql/fix-offers-status.sql similarity index 100% rename from fix-offers-status.sql rename to archive/sql/fix-offers-status.sql diff --git a/jetshare_seed.sql b/archive/sql/jetshare_seed.sql similarity index 100% rename from jetshare_seed.sql rename to archive/sql/jetshare_seed.sql diff --git a/migration_embedding_logs.sql b/archive/sql/migration_embedding_logs.sql similarity index 100% rename from migration_embedding_logs.sql rename to archive/sql/migration_embedding_logs.sql diff --git a/migration_fix_database_admin.sql b/archive/sql/migration_fix_database_admin.sql similarity index 100% rename from migration_fix_database_admin.sql rename to archive/sql/migration_fix_database_admin.sql diff --git a/migration_fix_embedding_logs.sql b/archive/sql/migration_fix_embedding_logs.sql similarity index 100% rename from migration_fix_embedding_logs.sql rename to archive/sql/migration_fix_embedding_logs.sql diff --git a/migration_fix_run_sql.sql b/archive/sql/migration_fix_run_sql.sql similarity index 100% rename from migration_fix_run_sql.sql rename to archive/sql/migration_fix_run_sql.sql diff --git a/seed-jets.sql b/archive/sql/seed-jets.sql similarity index 100% rename from seed-jets.sql rename to archive/sql/seed-jets.sql diff --git a/seed_marketplace_offers.sql b/archive/sql/seed_marketplace_offers.sql similarity index 100% rename from seed_marketplace_offers.sql rename to archive/sql/seed_marketplace_offers.sql diff --git a/cleanup-report.md b/cleanup-report.md new file mode 100644 index 00000000..b16b7321 --- /dev/null +++ b/cleanup-report.md @@ -0,0 +1,79 @@ +# JetStream Codebase Cleanup Report + +## Overview +This document outlines the changes made during the systematic cleanup and refactoring of the JetStream codebase, performed under the feature branch `feature/cleanup-archive-refactor`. + +## 1. SQL and Database Files +Moved the following SQL files to an organized archive structure: + +### Migrated to /archive/sql +- migration_fix_run_sql.sql +- migration_fix_database_admin.sql +- migration_fix_embedding_logs.sql +- migration_embedding_logs.sql +- concierge_voice_tables.sql +- fix-offers-status.sql +- seed_marketplace_offers.sql +- seed-jets.sql +- jetshare_seed.sql +- add_missing_columns_to_jets.sql + +### Migrated to /archive/sql/data-exports +- jets_rows.sql (renamed from "jets_rows (3).sql") +- jet_interiors_rows.sql (renamed from "jet_interiors_rows (1).sql") +- aircraft_models_rows.sql (renamed from "aircraft_models_rows (2).sql") + +## 2. Fix Scripts +Moved one-time data fix scripts to the archive: + +### Migrated to /archive/fix-scripts +- fix-crew-db.js +- fix-status-constraint.js +- run-add-missing-columns.js + +## 3. Documentation Organization +Organized documentation files into a more structured hierarchy: + +### Migrated to /docs/feature-docs +- AI-INFERENCE-SETUP-NOTES.md +- FIX-JETSHARE-STATUS.md +- FIX-STATUS-ISSUE.md +- JETSHARE-AI-CONCIERGE-DOCS.md +- PILOTS-CREW-FEATURE.md +- README-OPENAI.md +- README-jet-images.md + +### Migrated to /docs/project-docs +- MIGRATION-NEXT15.md +- PR-TEMPLATE-CONCIERGE.md + +## 4. Miscellaneous Files +Organized miscellaneous files into appropriate archive locations: + +### Migrated to /archive/logs +- supabase_logs.csv +- cookies.txt + +### Migrated to /archive/screenshots +- Screen Shot 2025-03-31 at 6.10.15 PM.png +- Screen Shot 2025-03-31 at 6.06.36 PM.png + +## 5. Bug Fixes + +### Fixed JetSeatVisualizer API Issue +- Modified `/app/api/jets/[id]/route.ts` to handle 'default' ID case specifically +- Added proper handling for the 'default' case to avoid database querying with invalid UUID +- Fixed NextRequest import to resolve TypeScript error + +## 6. Code Consolidation + +### Consolidated Utility Functions +- Removed duplicate formatting functions from `lib/utils.ts` +- Centralized formatting functions in `lib/utils/format.ts` +- Updated `lib/utils.ts` to re-export formatting functions to maintain backward compatibility + +## Next Steps +- Continue identifying and consolidating any other duplicated code +- Review and clean up unused dependencies in package.json +- Standardize naming conventions across the codebase +- Run linting and fix any style issues \ No newline at end of file diff --git a/AI-INFERENCE-SETUP-NOTES.md b/docs/feature-docs/AI-INFERENCE-SETUP-NOTES.md similarity index 100% rename from AI-INFERENCE-SETUP-NOTES.md rename to docs/feature-docs/AI-INFERENCE-SETUP-NOTES.md diff --git a/FIX-JETSHARE-STATUS.md b/docs/feature-docs/FIX-JETSHARE-STATUS.md similarity index 100% rename from FIX-JETSHARE-STATUS.md rename to docs/feature-docs/FIX-JETSHARE-STATUS.md diff --git a/FIX-STATUS-ISSUE.md b/docs/feature-docs/FIX-STATUS-ISSUE.md similarity index 100% rename from FIX-STATUS-ISSUE.md rename to docs/feature-docs/FIX-STATUS-ISSUE.md diff --git a/JETSHARE-AI-CONCIERGE-DOCS.md b/docs/feature-docs/JETSHARE-AI-CONCIERGE-DOCS.md similarity index 100% rename from JETSHARE-AI-CONCIERGE-DOCS.md rename to docs/feature-docs/JETSHARE-AI-CONCIERGE-DOCS.md diff --git a/PILOTS-CREW-FEATURE.md b/docs/feature-docs/PILOTS-CREW-FEATURE.md similarity index 100% rename from PILOTS-CREW-FEATURE.md rename to docs/feature-docs/PILOTS-CREW-FEATURE.md diff --git a/README-OPENAI.md b/docs/feature-docs/README-OPENAI.md similarity index 100% rename from README-OPENAI.md rename to docs/feature-docs/README-OPENAI.md diff --git a/README-jet-images.md b/docs/feature-docs/README-jet-images.md similarity index 100% rename from README-jet-images.md rename to docs/feature-docs/README-jet-images.md diff --git a/MIGRATION-NEXT15.md b/docs/project-docs/MIGRATION-NEXT15.md similarity index 100% rename from MIGRATION-NEXT15.md rename to docs/project-docs/MIGRATION-NEXT15.md diff --git a/PR-TEMPLATE-CONCIERGE.md b/docs/project-docs/PR-TEMPLATE-CONCIERGE.md similarity index 100% rename from PR-TEMPLATE-CONCIERGE.md rename to docs/project-docs/PR-TEMPLATE-CONCIERGE.md diff --git a/lib/utils.ts b/lib/utils.ts index 8dff2f5b..3ba93745 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,49 +1,10 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +import { formatDate, formatTime, formatCurrency } from "./utils/format" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -/** - * Formats a date string to a human-readable format - * @param dateString - ISO date string - * @returns Formatted date (e.g., "Jan 5, 2023") - */ -export function formatDate(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); -} - -/** - * Formats a date string to display time only - * @param dateString - ISO date string - * @returns Formatted time (e.g., "2:30 PM") - */ -export function formatTime(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true - }); -} - -/** - * Formats a number as currency - * @param amount - Number to format - * @param currency - Currency code (default: USD) - * @returns Formatted currency string - */ -export function formatCurrency(amount: number, currency: string = 'USD'): string { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 0 - }).format(amount); -} +// Re-export formatting utilities from utils/format.ts +export { formatDate, formatTime, formatCurrency } From c3e0005ff958224704dfee316acd5171db8be64a Mon Sep 17 00:00:00 2001 From: "m@" <35651510+BlockSavvy@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:12:53 -0500 Subject: [PATCH 002/215] chore: archive database fix and restore scripts, update cleanup report --- .../db/fix-scripts}/airport-flights-fixer.js | 0 .../db/fix-scripts}/final-flight-fixer.js | 0 .../db/fix-scripts}/fix-flights-direct.js | 0 .../fix-scripts}/fix-flights-sql-editor.sql | 0 .../db/fix-scripts}/fix-flights-v2.js | 0 .../fix-flights-with-airports.sql | 0 {db => archive/db/fix-scripts}/fix-flights.js | 0 .../db/fix-scripts}/fix-flights.sql | 0 .../db/fix-scripts}/flight-airports-fixer.js | 0 .../db/fix-scripts}/flight-fixer-backup.js | 0 .../db/fix-scripts}/flight-fixer.js | 0 .../db/restore-scripts}/restore-database.js | 0 .../restore-db-for-dashboard.sql | 0 .../db/restore-scripts}/restore-direct.js | 0 .../restore-jetstream-1-schema.sql | 0 .../restore-jetstream-2-crew-tables.sql | 0 .../restore-jetstream-3-jetshare-tables.sql | 0 .../restore-jetstream-4-rls-policies.sql | 0 .../restore-jetstream-5-triggers.sql | 0 .../restore-jetstream-6-seed-data.sql | 0 .../restore-jetstream-README.md | 0 .../restore-jetstream-complete.sql | 0 cleanup-report.md | 37 +++++++++++++++++++ 23 files changed, 37 insertions(+) rename {db => archive/db/fix-scripts}/airport-flights-fixer.js (100%) rename {db => archive/db/fix-scripts}/final-flight-fixer.js (100%) rename {db => archive/db/fix-scripts}/fix-flights-direct.js (100%) rename {db => archive/db/fix-scripts}/fix-flights-sql-editor.sql (100%) rename {db => archive/db/fix-scripts}/fix-flights-v2.js (100%) rename {db => archive/db/fix-scripts}/fix-flights-with-airports.sql (100%) rename {db => archive/db/fix-scripts}/fix-flights.js (100%) rename {db => archive/db/fix-scripts}/fix-flights.sql (100%) rename {db => archive/db/fix-scripts}/flight-airports-fixer.js (100%) rename {db => archive/db/fix-scripts}/flight-fixer-backup.js (100%) rename {db => archive/db/fix-scripts}/flight-fixer.js (100%) rename {db => archive/db/restore-scripts}/restore-database.js (100%) rename {db => archive/db/restore-scripts}/restore-db-for-dashboard.sql (100%) rename {db => archive/db/restore-scripts}/restore-direct.js (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-1-schema.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-2-crew-tables.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-3-jetshare-tables.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-4-rls-policies.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-5-triggers.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-6-seed-data.sql (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-README.md (100%) rename {db => archive/db/restore-scripts}/restore-jetstream-complete.sql (100%) diff --git a/db/airport-flights-fixer.js b/archive/db/fix-scripts/airport-flights-fixer.js similarity index 100% rename from db/airport-flights-fixer.js rename to archive/db/fix-scripts/airport-flights-fixer.js diff --git a/db/final-flight-fixer.js b/archive/db/fix-scripts/final-flight-fixer.js similarity index 100% rename from db/final-flight-fixer.js rename to archive/db/fix-scripts/final-flight-fixer.js diff --git a/db/fix-flights-direct.js b/archive/db/fix-scripts/fix-flights-direct.js similarity index 100% rename from db/fix-flights-direct.js rename to archive/db/fix-scripts/fix-flights-direct.js diff --git a/db/fix-flights-sql-editor.sql b/archive/db/fix-scripts/fix-flights-sql-editor.sql similarity index 100% rename from db/fix-flights-sql-editor.sql rename to archive/db/fix-scripts/fix-flights-sql-editor.sql diff --git a/db/fix-flights-v2.js b/archive/db/fix-scripts/fix-flights-v2.js similarity index 100% rename from db/fix-flights-v2.js rename to archive/db/fix-scripts/fix-flights-v2.js diff --git a/db/fix-flights-with-airports.sql b/archive/db/fix-scripts/fix-flights-with-airports.sql similarity index 100% rename from db/fix-flights-with-airports.sql rename to archive/db/fix-scripts/fix-flights-with-airports.sql diff --git a/db/fix-flights.js b/archive/db/fix-scripts/fix-flights.js similarity index 100% rename from db/fix-flights.js rename to archive/db/fix-scripts/fix-flights.js diff --git a/db/fix-flights.sql b/archive/db/fix-scripts/fix-flights.sql similarity index 100% rename from db/fix-flights.sql rename to archive/db/fix-scripts/fix-flights.sql diff --git a/db/flight-airports-fixer.js b/archive/db/fix-scripts/flight-airports-fixer.js similarity index 100% rename from db/flight-airports-fixer.js rename to archive/db/fix-scripts/flight-airports-fixer.js diff --git a/db/flight-fixer-backup.js b/archive/db/fix-scripts/flight-fixer-backup.js similarity index 100% rename from db/flight-fixer-backup.js rename to archive/db/fix-scripts/flight-fixer-backup.js diff --git a/db/flight-fixer.js b/archive/db/fix-scripts/flight-fixer.js similarity index 100% rename from db/flight-fixer.js rename to archive/db/fix-scripts/flight-fixer.js diff --git a/db/restore-database.js b/archive/db/restore-scripts/restore-database.js similarity index 100% rename from db/restore-database.js rename to archive/db/restore-scripts/restore-database.js diff --git a/db/restore-db-for-dashboard.sql b/archive/db/restore-scripts/restore-db-for-dashboard.sql similarity index 100% rename from db/restore-db-for-dashboard.sql rename to archive/db/restore-scripts/restore-db-for-dashboard.sql diff --git a/db/restore-direct.js b/archive/db/restore-scripts/restore-direct.js similarity index 100% rename from db/restore-direct.js rename to archive/db/restore-scripts/restore-direct.js diff --git a/db/restore-jetstream-1-schema.sql b/archive/db/restore-scripts/restore-jetstream-1-schema.sql similarity index 100% rename from db/restore-jetstream-1-schema.sql rename to archive/db/restore-scripts/restore-jetstream-1-schema.sql diff --git a/db/restore-jetstream-2-crew-tables.sql b/archive/db/restore-scripts/restore-jetstream-2-crew-tables.sql similarity index 100% rename from db/restore-jetstream-2-crew-tables.sql rename to archive/db/restore-scripts/restore-jetstream-2-crew-tables.sql diff --git a/db/restore-jetstream-3-jetshare-tables.sql b/archive/db/restore-scripts/restore-jetstream-3-jetshare-tables.sql similarity index 100% rename from db/restore-jetstream-3-jetshare-tables.sql rename to archive/db/restore-scripts/restore-jetstream-3-jetshare-tables.sql diff --git a/db/restore-jetstream-4-rls-policies.sql b/archive/db/restore-scripts/restore-jetstream-4-rls-policies.sql similarity index 100% rename from db/restore-jetstream-4-rls-policies.sql rename to archive/db/restore-scripts/restore-jetstream-4-rls-policies.sql diff --git a/db/restore-jetstream-5-triggers.sql b/archive/db/restore-scripts/restore-jetstream-5-triggers.sql similarity index 100% rename from db/restore-jetstream-5-triggers.sql rename to archive/db/restore-scripts/restore-jetstream-5-triggers.sql diff --git a/db/restore-jetstream-6-seed-data.sql b/archive/db/restore-scripts/restore-jetstream-6-seed-data.sql similarity index 100% rename from db/restore-jetstream-6-seed-data.sql rename to archive/db/restore-scripts/restore-jetstream-6-seed-data.sql diff --git a/db/restore-jetstream-README.md b/archive/db/restore-scripts/restore-jetstream-README.md similarity index 100% rename from db/restore-jetstream-README.md rename to archive/db/restore-scripts/restore-jetstream-README.md diff --git a/db/restore-jetstream-complete.sql b/archive/db/restore-scripts/restore-jetstream-complete.sql similarity index 100% rename from db/restore-jetstream-complete.sql rename to archive/db/restore-scripts/restore-jetstream-complete.sql diff --git a/cleanup-report.md b/cleanup-report.md index b16b7321..6ec91051 100644 --- a/cleanup-report.md +++ b/cleanup-report.md @@ -23,6 +23,32 @@ Moved the following SQL files to an organized archive structure: - jet_interiors_rows.sql (renamed from "jet_interiors_rows (1).sql") - aircraft_models_rows.sql (renamed from "aircraft_models_rows (2).sql") +### Migrated to /archive/db/fix-scripts +- flight-airports-fixer.js +- flight-fixer-backup.js +- flight-fixer.js +- fix-flights-direct.js +- fix-flights-sql-editor.sql +- fix-flights-v2.js +- fix-flights-with-airports.sql +- fix-flights.js +- fix-flights.sql +- final-flight-fixer.js +- airport-flights-fixer.js + +### Migrated to /archive/db/restore-scripts +- restore-jetstream-1-schema.sql +- restore-jetstream-2-crew-tables.sql +- restore-jetstream-3-jetshare-tables.sql +- restore-jetstream-4-rls-policies.sql +- restore-jetstream-5-triggers.sql +- restore-jetstream-6-seed-data.sql +- restore-jetstream-complete.sql +- restore-jetstream-README.md +- restore-direct.js +- restore-db-for-dashboard.sql +- restore-database.js + ## 2. Fix Scripts Moved one-time data fix scripts to the archive: @@ -72,6 +98,17 @@ Organized miscellaneous files into appropriate archive locations: - Centralized formatting functions in `lib/utils/format.ts` - Updated `lib/utils.ts` to re-export formatting functions to maintain backward compatibility +## 7. Next.js Configuration +- Analyzed both next.config.js and next.config.mjs +- Found significant differences in functionality: + - next.config.mjs supports user config merging and ES modules + - next.config.js has different webpack configs and module resolution +- Kept both files as they appear to serve different purposes + +## 8. TODO Items +- Identified several TODO comments and temporary settings in config files +- Left TypeScript error handling settings in place as they appear to be intentional + ## Next Steps - Continue identifying and consolidating any other duplicated code - Review and clean up unused dependencies in package.json From 255d6831c376f62cadf55719eeacf933d7cb2bd6 Mon Sep 17 00:00:00 2001 From: "m@" <35651510+BlockSavvy@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:37:08 -0500 Subject: [PATCH 003/215] fix: improve seat visualizer accuracy and fix cookies handling --- app/api/jets/[id]/route.ts | 87 ++++++++++++++++++++++++++++----- cleanup-report.md | 19 +++++-- migrations/jet_seat_layouts.sql | 62 +++++++++++++++++++++++ 3 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 migrations/jet_seat_layouts.sql diff --git a/app/api/jets/[id]/route.ts b/app/api/jets/[id]/route.ts index 5a1c1459..653e7747 100644 --- a/app/api/jets/[id]/route.ts +++ b/app/api/jets/[id]/route.ts @@ -70,8 +70,9 @@ export const GET: GetRouteHandler<{ id: string }> = async ( }); } - // Initialize Supabase client - const supabase = createServerComponentClient({ cookies }); + // Initialize Supabase client with the correct cookie handling + const cookieStore = cookies(); + const supabase = createServerComponentClient({ cookies: () => cookieStore }); // Get jet data const { data: jet, error: jetError } = await supabase @@ -104,16 +105,29 @@ export const GET: GetRouteHandler<{ id: string }> = async ( // Continue without interior data } - // Get jet seat layout data - const { data: seatLayoutData, error: layoutError } = await supabase - .from('jet_seat_layouts') - .select('layout') - .eq('jet_id', jet_id) - .maybeSingle(); + // Try to get jet seat layout data - but handle the case where table doesn't exist yet + let seatLayoutData = null; + let layoutError = null; - if (layoutError) { - console.error('Error fetching seat layout:', layoutError); - // Continue without seat layout data + try { + const result = await supabase + .from('jet_seat_layouts') + .select('layout') + .eq('jet_id', jet_id) + .maybeSingle(); + + seatLayoutData = result.data; + layoutError = result.error; + + if (layoutError && layoutError.code === '42P01') { + console.log('Seat layout table does not exist yet, using default layout'); + layoutError = null; // Clear the error since we'll handle it gracefully + } else if (layoutError) { + console.error('Error fetching seat layout:', layoutError); + } + } catch (err) { + console.error('Error in seat layout query:', err); + // Continue without layout data } // Create a default seat layout based on capacity if no custom layout exists @@ -124,9 +138,10 @@ export const GET: GetRouteHandler<{ id: string }> = async ( seatLayout = seatLayoutData.layout; } else { // Create a default layout based on capacity - const capacity = jet.capacity || (interior?.seats || 4); + const capacity = jet.capacity || (interior?.seats ? parseInt(interior.seats) : 4); let rows = 0; let seatsPerRow = 0; + let skipPositions: number[][] = []; // Calculate a reasonable layout if (capacity <= 4) { @@ -135,24 +150,70 @@ export const GET: GetRouteHandler<{ id: string }> = async ( } else if (capacity <= 8) { rows = 2; seatsPerRow = 4; + + // Handle non-standard counts (5, 6, 7) + const extraSeats = (rows * seatsPerRow) - capacity; + if (extraSeats > 0) { + // Skip seats from the last row, right to left + for (let i = 0; i < extraSeats; i++) { + skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + } + } } else if (capacity <= 12) { rows = 3; seatsPerRow = 4; + + // Handle non-standard counts (9, 10, 11) + const extraSeats = (rows * seatsPerRow) - capacity; + if (extraSeats > 0) { + // Skip seats from the last row, right to left + for (let i = 0; i < extraSeats; i++) { + skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + } + } } else if (capacity <= 16) { rows = 4; seatsPerRow = 4; + + // Handle non-standard counts (13, 14, 15) + const extraSeats = (rows * seatsPerRow) - capacity; + if (extraSeats > 0) { + // Skip seats from the last row, right to left + for (let i = 0; i < extraSeats; i++) { + skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + } + } } else { rows = 5; seatsPerRow = 4; + + // Handle non-standard counts (17, 18, 19) + const extraSeats = (rows * seatsPerRow) - capacity; + if (extraSeats > 0) { + // Skip seats from the last row, right to left + for (let i = 0; i < extraSeats; i++) { + skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + } + } } + // Create the layout object seatLayout = { rows, seatsPerRow, layoutType: 'standard', totalSeats: capacity, - skipPositions: [] + seatMap: { + skipPositions: skipPositions + } }; + + console.log(`Generated layout for ${capacity} seats:`, { + rows, + seatsPerRow, + skipPositions, + actualSeats: (rows * seatsPerRow) - skipPositions.length + }); } // Return combined data diff --git a/cleanup-report.md b/cleanup-report.md index 6ec91051..ac7c5b71 100644 --- a/cleanup-report.md +++ b/cleanup-report.md @@ -49,6 +49,9 @@ Moved the following SQL files to an organized archive structure: - restore-db-for-dashboard.sql - restore-database.js +### Added New Migrations +- Added jet_seat_layouts.sql migration to create the missing table for custom jet seat layouts + ## 2. Fix Scripts Moved one-time data fix scripts to the archive: @@ -86,10 +89,17 @@ Organized miscellaneous files into appropriate archive locations: ## 5. Bug Fixes -### Fixed JetSeatVisualizer API Issue +### Fixed JetSeatVisualizer API Issues - Modified `/app/api/jets/[id]/route.ts` to handle 'default' ID case specifically -- Added proper handling for the 'default' case to avoid database querying with invalid UUID -- Fixed NextRequest import to resolve TypeScript error +- Fixed cookie handling to properly handle cookies as per Next.js requirements +- Improved error handling for missing jet_seat_layouts table +- Added jet_seat_layouts.sql migration to create the required table + +### Fixed Seat Visualizer Display Issues +- Enhanced the layout calculation to properly handle non-standard seat counts +- Added proper skipPositions calculation to ensure correct number of seats display +- Now displays exactly the number of seats specified in the jet's capacity or interior.seats +- Improved logging to help troubleshoot seat layout issues ## 6. Code Consolidation @@ -113,4 +123,5 @@ Organized miscellaneous files into appropriate archive locations: - Continue identifying and consolidating any other duplicated code - Review and clean up unused dependencies in package.json - Standardize naming conventions across the codebase -- Run linting and fix any style issues \ No newline at end of file +- Run linting and fix any style issues +- Create comprehensive documentation for both the main application and JetShare feature \ No newline at end of file diff --git a/migrations/jet_seat_layouts.sql b/migrations/jet_seat_layouts.sql new file mode 100644 index 00000000..20573d8a --- /dev/null +++ b/migrations/jet_seat_layouts.sql @@ -0,0 +1,62 @@ +-- Create jet_seat_layouts table if it doesn't exist +CREATE TABLE IF NOT EXISTS public.jet_seat_layouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + jet_id UUID NOT NULL REFERENCES public.jets(id) ON DELETE CASCADE, + layout JSONB NOT NULL, -- Store the entire seat layout JSON object + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Add comment to table +COMMENT ON TABLE public.jet_seat_layouts IS 'Custom seat layouts for specific jets'; + +-- Add row-level security policies +ALTER TABLE public.jet_seat_layouts ENABLE ROW LEVEL SECURITY; + +-- Policy to allow reading by any authenticated user +CREATE POLICY "Allow reading layouts" + ON public.jet_seat_layouts + FOR SELECT + USING (true); + +-- Policy to allow jet owners to update layouts +CREATE POLICY "Allow owners to update layouts" + ON public.jet_seat_layouts + FOR UPDATE + USING ( + auth.uid() IN ( + SELECT j.owner_id + FROM public.jets j + WHERE j.id = jet_seat_layouts.jet_id + ) + ); + +-- Policy to allow jet owners to insert layouts +CREATE POLICY "Allow owners to insert layouts" + ON public.jet_seat_layouts + FOR INSERT + WITH CHECK ( + auth.uid() IN ( + SELECT j.owner_id + FROM public.jets j + WHERE j.id = jet_seat_layouts.jet_id + ) + ); + +-- Create index for faster lookup +CREATE INDEX IF NOT EXISTS jet_seat_layouts_jet_id_idx ON public.jet_seat_layouts(jet_id); + +-- Add function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_jet_seat_layouts_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to update timestamp +CREATE TRIGGER update_jet_seat_layouts_updated_at +BEFORE UPDATE ON public.jet_seat_layouts +FOR EACH ROW +EXECUTE FUNCTION update_jet_seat_layouts_updated_at(); \ No newline at end of file From 5aa774341ef3ea4538ba32042536888e08bfba5d Mon Sep 17 00:00:00 2001 From: "m@" <35651510+BlockSavvy@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:08:34 -0500 Subject: [PATCH 004/215] fix: improve admin navigation and fix cookie handling in APIs --- app/admin/components/sidebar.tsx | 115 +++++++-- app/admin/jets/layout-editor.tsx | 331 ++++++++++++++++++++++++++ app/admin/jets/layouts/page.tsx | 27 +++ app/api/admin/jets/route.ts | 38 +++ app/api/admin/jets/setLayout/route.ts | 162 +++++++++++++ app/api/jets/[id]/route.ts | 64 ++--- 6 files changed, 675 insertions(+), 62 deletions(-) create mode 100644 app/admin/jets/layout-editor.tsx create mode 100644 app/admin/jets/layouts/page.tsx create mode 100644 app/api/admin/jets/route.ts create mode 100644 app/api/admin/jets/setLayout/route.ts diff --git a/app/admin/components/sidebar.tsx b/app/admin/components/sidebar.tsx index 740ab26e..788764d8 100644 --- a/app/admin/components/sidebar.tsx +++ b/app/admin/components/sidebar.tsx @@ -12,11 +12,25 @@ import { UserCog, BrainCircuit, Database, - Braces + Braces, + Grid } from 'lucide-react'; +// Define the type for navigation items +type NavItem = { + name: string; + href: string | undefined; + icon: any; + isCategory?: boolean; + submenu?: { + name: string; + href: string; + icon?: any; + }[]; +}; + // Define navigation items -const navigationItems = [ +const navigationItems: NavItem[] = [ { name: 'Overview', href: '/admin/overview', @@ -29,8 +43,20 @@ const navigationItems = [ }, { name: 'Jets', - href: '/admin/jets', - icon: Plane + href: undefined, + icon: Plane, + isCategory: true, + submenu: [ + { + name: 'Jets List', + href: '/admin/jets' + }, + { + name: 'Seat Layouts', + href: '/admin/jets/layouts', + icon: Grid + } + ] }, { name: 'JetStream Flights', @@ -77,28 +103,71 @@ export default function Sidebar() { diff --git a/app/admin/jets/layout-editor.tsx b/app/admin/jets/layout-editor.tsx new file mode 100644 index 00000000..35ff965b --- /dev/null +++ b/app/admin/jets/layout-editor.tsx @@ -0,0 +1,331 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { toast } from 'sonner'; +import { Loader2, Check, RefreshCw, Save } from 'lucide-react'; + +type PresetLayout = { + value: string; + label: string; +}; + +type PresetCategory = { + label: string; + options: PresetLayout[]; +}; + +type PresetLayouts = { + [key: string]: PresetCategory; +}; + +type Jet = { + id: string; + model: string; + manufacturer: string; + capacity: number; +}; + +type SeatLayout = { + rows: number; + seatsPerRow: number; + layoutType: string; + totalSeats: number; + seatMap?: { + skipPositions?: number[][]; + customPositions?: { row: number; col: number; id: string }[]; + }; +}; + +export default function JetLayoutEditor() { + const [jets, setJets] = useState([]); + const [selectedJetId, setSelectedJetId] = useState(''); + const [selectedJet, setSelectedJet] = useState(null); + const [currentLayout, setCurrentLayout] = useState(null); + const [presetLayouts, setPresetLayouts] = useState(null); + const [selectedPresetCategory, setSelectedPresetCategory] = useState(''); + const [selectedPresetLayout, setSelectedPresetLayout] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + // Load all jets + useEffect(() => { + async function loadJets() { + try { + const response = await fetch('/api/admin/jets'); + if (!response.ok) { + throw new Error('Failed to load jets'); + } + const data = await response.json(); + setJets(data.jets || []); + } catch (error) { + console.error('Error loading jets:', error); + toast.error('Failed to load jets'); + } + } + + loadJets(); + }, []); + + // Load preset layouts + useEffect(() => { + async function loadPresetLayouts() { + try { + const response = await fetch('/api/admin/jets/setLayout'); + if (!response.ok) { + throw new Error('Failed to load preset layouts'); + } + const data = await response.json(); + setPresetLayouts(data.presetLayouts || {}); + } catch (error) { + console.error('Error loading preset layouts:', error); + toast.error('Failed to load preset layouts'); + } + } + + loadPresetLayouts(); + }, []); + + // Load current layout when a jet is selected + useEffect(() => { + if (!selectedJetId) { + setCurrentLayout(null); + setSelectedJet(null); + return; + } + + async function loadJetLayout() { + setIsLoading(true); + try { + const response = await fetch(`/api/jets/${selectedJetId}`); + if (!response.ok) { + throw new Error('Failed to load jet layout'); + } + const data = await response.json(); + setCurrentLayout(data.seatLayout || null); + setSelectedJet(data.jet || null); + } catch (error) { + console.error('Error loading jet layout:', error); + toast.error('Failed to load jet layout'); + } finally { + setIsLoading(false); + } + } + + loadJetLayout(); + }, [selectedJetId]); + + // Generate layout from preset selection + function generateLayoutFromPreset() { + if (!selectedPresetLayout || !selectedJet) return; + + const [rows, cols] = selectedPresetLayout.split('x').map(Number); + + // Calculate total seats based on the preset + const totalSeats = rows * cols; + + // If the total seats don't match the jet capacity, we'll adapt + // The seat layout will display exactly the selected preset configuration + + const newLayout: SeatLayout = { + rows, + seatsPerRow: cols, + layoutType: 'custom', + totalSeats: Math.min(totalSeats, selectedJet.capacity || totalSeats), + seatMap: { + skipPositions: [] + } + }; + + // If the jet capacity is less than the total preset seats, + // add skipPositions for the extra seats + if (selectedJet.capacity < totalSeats) { + const extraSeats = totalSeats - selectedJet.capacity; + for (let i = 0; i < extraSeats; i++) { + const row = Math.floor((totalSeats - 1 - i) / cols); + const col = (totalSeats - 1 - i) % cols; + newLayout.seatMap!.skipPositions!.push([row, col]); + } + } + + setCurrentLayout(newLayout); + } + + // Save the current layout to the database + async function saveLayout() { + if (!selectedJetId || !currentLayout) return; + + setIsSaving(true); + try { + const response = await fetch('/api/admin/jets/setLayout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jetId: selectedJetId, + layout: currentLayout + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save layout'); + } + + toast.success('Seat layout saved successfully'); + setIsSuccess(true); + + // Reset success state after 2 seconds + setTimeout(() => { + setIsSuccess(false); + }, 2000); + } catch (error) { + console.error('Error saving layout:', error); + toast.error(error instanceof Error ? error.message : 'Failed to save layout'); + } finally { + setIsSaving(false); + } + } + + return ( + + + Jet Seat Layout Editor + Configure custom seating layouts for jets + + + {/* Jet Selection */} +
+ + +
+ + {isLoading ? ( +
+ + Loading jet configuration... +
+ ) : selectedJet && ( + <> + {/* Current Layout Display */} +
+

Current Layout

+ {currentLayout ? ( +
+

Rows: {currentLayout.rows}

+

Seats per row: {currentLayout.seatsPerRow}

+

Total seats: {currentLayout.totalSeats}

+

Layout type: {currentLayout.layoutType}

+

Skipped positions: {currentLayout.seatMap?.skipPositions?.length || 0}

+
+ ) : ( +

No custom layout configured. Using auto-generated layout.

+ )} +
+ + {/* Layout Presets */} + {presetLayouts && ( +
+

Configure New Layout

+ +
+ + +
+ + {selectedPresetCategory && ( +
+ + +
+ )} + + {selectedPresetLayout && ( + + )} +
+ )} + + )} +
+ + + + +
+ ); +} \ No newline at end of file diff --git a/app/admin/jets/layouts/page.tsx b/app/admin/jets/layouts/page.tsx new file mode 100644 index 00000000..10ef28ee --- /dev/null +++ b/app/admin/jets/layouts/page.tsx @@ -0,0 +1,27 @@ +import { Metadata } from 'next'; +import JetLayoutEditor from '../layout-editor'; + +export const metadata: Metadata = { + title: 'Jet Seat Layout Editor', + description: 'Configure custom seating layouts for jets', +}; + +export default function JetLayoutsPage() { + return ( +
+
+
+

+ Jet Seat Layouts +

+

+ Configure how seats are arranged in each jet for the JetShare mobile app. +

+
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/api/admin/jets/route.ts b/app/api/admin/jets/route.ts new file mode 100644 index 00000000..fa2b234c --- /dev/null +++ b/app/api/admin/jets/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; + +export async function GET(request: NextRequest) { + try { + // Initialize Supabase client with proper cookie handling + const cookieStore = cookies(); + const supabase = createServerComponentClient({ cookies: () => cookieStore }); + + // Check if the user is authenticated + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + // Get all jets + const { data: jets, error } = await supabase + .from('jets') + .select('id, model, manufacturer, capacity, image_url') + .order('manufacturer', { ascending: true }) + .order('model', { ascending: true }); + + if (error) { + console.error('Error fetching jets:', error); + return NextResponse.json({ error: 'Failed to fetch jets' }, { status: 500 }); + } + + return NextResponse.json({ jets }); + } catch (error) { + console.error('Error processing request:', error); + return NextResponse.json({ + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/admin/jets/setLayout/route.ts b/app/api/admin/jets/setLayout/route.ts new file mode 100644 index 00000000..530cd49f --- /dev/null +++ b/app/api/admin/jets/setLayout/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; + +// Admin-only endpoint to set a custom seat layout for a jet +export async function POST(request: NextRequest) { + try { + // Initialize Supabase client with proper cookie handling + const cookieStore = cookies(); + const supabase = createServerComponentClient({ cookies: () => cookieStore }); + + // Check if the user is authenticated and has admin privileges + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + // Check if user is an admin (modify this according to your auth system) + const { data: isAdmin } = await supabase + .from('profiles') + .select('is_admin') + .eq('id', user.id) + .single(); + + if (!isAdmin?.is_admin) { + return NextResponse.json({ error: 'Admin privileges required' }, { status: 403 }); + } + + // Parse the request body + const { jetId, layout } = await request.json(); + + if (!jetId || !layout) { + return NextResponse.json({ error: 'Missing required fields: jetId, layout' }, { status: 400 }); + } + + // Ensure layout has required properties + if (!layout.rows || !layout.seatsPerRow || !layout.totalSeats) { + return NextResponse.json({ + error: 'Layout must include rows, seatsPerRow, and totalSeats' + }, { status: 400 }); + } + + // Check if the jet exists + const { data: jet, error: jetError } = await supabase + .from('jets') + .select('id') + .eq('id', jetId) + .single(); + + if (jetError || !jet) { + return NextResponse.json({ error: 'Jet not found' }, { status: 404 }); + } + + // Check if a layout already exists for this jet + const { data: existingLayout } = await supabase + .from('jet_seat_layouts') + .select('id') + .eq('jet_id', jetId) + .maybeSingle(); + + let result; + + if (existingLayout) { + // Update existing layout + result = await supabase + .from('jet_seat_layouts') + .update({ layout }) + .eq('jet_id', jetId) + .select(); + } else { + // Insert new layout + result = await supabase + .from('jet_seat_layouts') + .insert({ jet_id: jetId, layout }) + .select(); + } + + if (result.error) { + console.error('Database error:', result.error); + return NextResponse.json({ + error: 'Failed to save layout', + details: result.error.message + }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Seat layout saved successfully', + data: result.data[0] + }); + + } catch (error) { + console.error('Error processing request:', error); + return NextResponse.json({ + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} + +// Admin-only endpoint to get all available preset layouts +export async function GET(request: NextRequest) { + try { + // Initialize Supabase client with proper cookie handling + const cookieStore = cookies(); + const supabase = createServerComponentClient({ cookies: () => cookieStore }); + + // Check if the user is authenticated + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + // Return available preset layouts + const presetLayouts = { + // 2 seats per row layouts + 'private-2-seats': { + label: '2 seats per row (A1-A2, B1-B2, etc.)', + options: [ + { value: '2x2', label: '2 rows x 2 seats (4 total)' }, + { value: '3x2', label: '3 rows x 2 seats (6 total)' }, + { value: '4x2', label: '4 rows x 2 seats (8 total)' }, + { value: '5x2', label: '5 rows x 2 seats (10 total)' }, + { value: '6x2', label: '6 rows x 2 seats (12 total)' }, + { value: '7x2', label: '7 rows x 2 seats (14 total)' }, + { value: '8x2', label: '8 rows x 2 seats (16 total)' }, + ] + }, + // 3 seats per row layouts + 'mid-size-3-seats': { + label: '3 seats per row (A1-A3, B1-B3, etc.)', + options: [ + { value: '3x3', label: '3 rows x 3 seats (9 total)' }, + { value: '4x3', label: '4 rows x 3 seats (12 total)' }, + { value: '5x3', label: '5 rows x 3 seats (15 total)' }, + { value: '6x3', label: '6 rows x 3 seats (18 total)' }, + { value: '7x3', label: '7 rows x 3 seats (21 total)' }, + ] + }, + // 4 seats per row layouts + 'commercial-4-seats': { + label: '4 seats per row (A1-A4, B1-B4, etc.)', + options: [ + { value: '3x4', label: '3 rows x 4 seats (12 total)' }, + { value: '4x4', label: '4 rows x 4 seats (16 total)' }, + { value: '5x4', label: '5 rows x 4 seats (20 total)' }, + { value: '6x4', label: '6 rows x 4 seats (24 total)' }, + ] + } + }; + + return NextResponse.json({ presetLayouts }); + + } catch (error) { + console.error('Error processing request:', error); + return NextResponse.json({ + error: 'Internal server error' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/jets/[id]/route.ts b/app/api/jets/[id]/route.ts index 653e7747..b94ee308 100644 --- a/app/api/jets/[id]/route.ts +++ b/app/api/jets/[id]/route.ts @@ -143,56 +143,42 @@ export const GET: GetRouteHandler<{ id: string }> = async ( let seatsPerRow = 0; let skipPositions: number[][] = []; - // Calculate a reasonable layout + // Configure typical aircraft layout - prioritize 2 seats per row for most jets if (capacity <= 4) { + // For very small jets, 2 rows of 2 seats rows = 2; seatsPerRow = 2; - } else if (capacity <= 8) { - rows = 2; - seatsPerRow = 4; - - // Handle non-standard counts (5, 6, 7) - const extraSeats = (rows * seatsPerRow) - capacity; - if (extraSeats > 0) { - // Skip seats from the last row, right to left - for (let i = 0; i < extraSeats; i++) { - skipPositions.push([rows - 1, seatsPerRow - 1 - i]); - } - } - } else if (capacity <= 12) { - rows = 3; - seatsPerRow = 4; + } else if (capacity <= 16) { + // For most private jets, use 2 seats per row + seatsPerRow = 2; + rows = Math.ceil(capacity / seatsPerRow); - // Handle non-standard counts (9, 10, 11) - const extraSeats = (rows * seatsPerRow) - capacity; - if (extraSeats > 0) { - // Skip seats from the last row, right to left - for (let i = 0; i < extraSeats; i++) { - skipPositions.push([rows - 1, seatsPerRow - 1 - i]); - } + // Handle odd number of seats by skipping the last position + if (capacity % 2 !== 0) { + skipPositions.push([rows - 1, 1]); // Skip last seat in last row } - } else if (capacity <= 16) { - rows = 4; - seatsPerRow = 4; + } else if (capacity <= 30) { + // For larger jets, use 3 seats per row + seatsPerRow = 3; + rows = Math.ceil(capacity / seatsPerRow); - // Handle non-standard counts (13, 14, 15) - const extraSeats = (rows * seatsPerRow) - capacity; - if (extraSeats > 0) { - // Skip seats from the last row, right to left - for (let i = 0; i < extraSeats; i++) { - skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + // Handle seats that don't fill the last row + const lastRowPositions = capacity % seatsPerRow; + if (lastRowPositions > 0) { + for (let i = lastRowPositions; i < seatsPerRow; i++) { + skipPositions.push([rows - 1, i]); } } } else { - rows = 5; + // For commercial aircraft, use 4 seats per row seatsPerRow = 4; + rows = Math.ceil(capacity / seatsPerRow); - // Handle non-standard counts (17, 18, 19) - const extraSeats = (rows * seatsPerRow) - capacity; - if (extraSeats > 0) { - // Skip seats from the last row, right to left - for (let i = 0; i < extraSeats; i++) { - skipPositions.push([rows - 1, seatsPerRow - 1 - i]); + // Handle seats that don't fill the last row + const lastRowPositions = capacity % seatsPerRow; + if (lastRowPositions > 0) { + for (let i = lastRowPositions; i < seatsPerRow; i++) { + skipPositions.push([rows - 1, i]); } } } From 05511e1843ec49e3f20fc8ee3bafc617daaea588 Mon Sep 17 00:00:00 2001 From: "m@" <35651510+BlockSavvy@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:31:11 -0500 Subject: [PATCH 005/215] fix: use createClient() from supabase-server.ts to resolve auth issues --- app/admin/jets/layout-editor.tsx | 40 +++++++++++++++++++++++++-- app/api/admin/jets/route.ts | 15 ++-------- app/api/admin/jets/setLayout/route.ts | 40 ++++----------------------- 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/app/admin/jets/layout-editor.tsx b/app/admin/jets/layout-editor.tsx index 35ff965b..ff6ccaec 100644 --- a/app/admin/jets/layout-editor.tsx +++ b/app/admin/jets/layout-editor.tsx @@ -55,15 +55,25 @@ export default function JetLayoutEditor() { useEffect(() => { async function loadJets() { try { + setIsLoading(true); const response = await fetch('/api/admin/jets'); + if (!response.ok) { + if (response.status === 401) { + // Let the app's built-in auth system handle redirects for authentication + toast.error('Authentication required'); + return; + } throw new Error('Failed to load jets'); } + const data = await response.json(); setJets(data.jets || []); } catch (error) { console.error('Error loading jets:', error); toast.error('Failed to load jets'); + } finally { + setIsLoading(false); } } @@ -75,9 +85,15 @@ export default function JetLayoutEditor() { async function loadPresetLayouts() { try { const response = await fetch('/api/admin/jets/setLayout'); + if (!response.ok) { + if (response.status === 401) { + // Let the app's built-in auth system handle redirects + return; + } throw new Error('Failed to load preset layouts'); } + const data = await response.json(); setPresetLayouts(data.presetLayouts || {}); } catch (error) { @@ -172,6 +188,13 @@ export default function JetLayoutEditor() { }); if (!response.ok) { + // Handle 401 authentication errors using the app's built-in auth system + if (response.status === 401) { + toast.error('Authentication required'); + setIsSaving(false); + return; + } + const errorData = await response.json(); throw new Error(errorData.error || 'Failed to save layout'); } @@ -203,7 +226,10 @@ export default function JetLayoutEditor() { { + // Handle state update in event handler, not during render + setSelectedPresetCategory(value); + // Reset the layout selection when category changes + setSelectedPresetLayout(''); + }} > @@ -270,7 +301,10 @@ export default function JetLayoutEditor() { + )} + + {/* Rescue button */} + {error && rescueButton} + + ); +} + +// Public API - This is the component that gets exported and used +export default function AircraftModelSelector(props: AircraftModelSelectorProps) { + // Use a client-side effect to handle the non-serializable callbacks + const [mounted, setMounted] = useState(false); + + // Ensure component only renders on client side + useEffect(() => { + setMounted(true); + }, []); + + // Don't render until client-side to avoid hydration issues + if (!mounted) { + return
; + } + + // Transform serializable props to actual function handlers + const clientProps: ClientAircraftModelSelectorProps = { + ...props, + onChange: (value: string, seatCapacity?: number) => { + // Handle onChange in the client component + // For state management, you would typically use this with a useState hook in the parent + if (window) { + // Dispatch a custom event that can be listened to by parent components + window.dispatchEvent(new CustomEvent('aircraftModelChange', { + detail: { value, seatCapacity } + })); + } + + // Alternative approach: Use local storage for simple state persistence + try { + localStorage.setItem('selectedAircraftModel', value); + if (seatCapacity) { + localStorage.setItem('selectedAircraftCapacity', seatCapacity.toString()); + } + } catch (e) { + console.error('Failed to store aircraft selection: ', e); + } + }, + onCustomChange: props.onCustomChangeValue ? + (value: string) => { + if (window) { + window.dispatchEvent(new CustomEvent('aircraftCustomChange', { + detail: { value } + })); + } + } : + undefined + }; + + return ; +} \ No newline at end of file diff --git a/app/gdyup/components/BoardingPassButton.tsx b/app/gdyup/components/BoardingPassButton.tsx new file mode 100644 index 00000000..59d04507 --- /dev/null +++ b/app/gdyup/components/BoardingPassButton.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Ticket, Wallet, Download, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; + +interface BoardingPassButtonProps { + transactionId?: string; + offerId: string; + isTestMode?: boolean; +} + +export default function BoardingPassButton({ + transactionId, + offerId, + isTestMode = false +}: BoardingPassButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [isAppleWalletLoading, setIsAppleWalletLoading] = useState(false); + + const downloadBoardingPass = async () => { + setIsLoading(true); + + try { + // Build the query parameters based on available IDs + const idParam = transactionId + ? `transactionId=${transactionId}` + : `offerId=${offerId}`; + + // Add test flag if needed + const testParam = isTestMode ? '&test=true' : ''; + + // Call the API to generate boarding pass + const response = await fetch(`/api/jetshare/generateBoardingPass?${idParam}${testParam}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to generate boarding pass'); + } + + // Open the boarding pass in a new tab + window.open(data.downloadUrl, '_blank'); + toast.success('Boarding pass downloaded successfully'); + } catch (err) { + console.error('Error downloading boarding pass:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to download boarding pass'; + toast.error(errorMsg); + } finally { + setIsLoading(false); + } + }; + + const addToAppleWallet = async () => { + setIsAppleWalletLoading(true); + + try { + // Build the query parameters based on available IDs + const idParam = transactionId + ? `transactionId=${transactionId}` + : `offerId=${offerId}`; + + // Add test flag if needed + const testParam = isTestMode ? '&test=true' : ''; + + // Call the API to generate Apple Wallet pass + const response = await fetch(`/api/jetshare/generateBoardingPass?${idParam}${testParam}&format=wallet`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to generate Apple Wallet pass'); + } + + // In a real production app, this would open a .pkpass file that the OS would recognize + // For our demo, we'll just open the simulated wallet pass endpoint + window.open(data.walletUrl, '_blank'); + toast.success('Boarding pass added to Apple Wallet', { + description: isTestMode ? 'Test mode: This is a simulated Apple Wallet pass' : undefined + }); + } catch (err) { + console.error('Error generating Apple Wallet pass:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to add to Apple Wallet'; + toast.error(errorMsg); + } finally { + setIsAppleWalletLoading(false); + } + }; + + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/app/gdyup/components/DevModeHelpers.tsx b/app/gdyup/components/DevModeHelpers.tsx new file mode 100644 index 00000000..00d49194 --- /dev/null +++ b/app/gdyup/components/DevModeHelpers.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; +import { Code } from '@/components/ui/code'; +import { Badge } from '@/components/ui/badge'; +import { Terminal, Database, AlertTriangle, CheckCircle, RefreshCw, FileCode } from 'lucide-react'; +import { toast } from 'sonner'; + +/** + * Development helper component for JetShare module + * Provides tools for database setup, checking, and seeding + */ +export function DevModeHelpers() { + const [dbStatus, setDbStatus] = useState(null); + const [loadingAction, setLoadingAction] = useState(null); + const [sqlScript, setSqlScript] = useState(null); + + if (process.env.NODE_ENV !== 'development') { + return null; + } + + const checkDatabase = async () => { + setLoadingAction('check'); + try { + const response = await fetch('/api/jetshare/check-db'); + const data = await response.json(); + setDbStatus(data); + + if (data.success) { + toast.success('Database check completed successfully'); + } else { + toast.error('Database check found issues'); + } + } catch (error) { + console.error('Error checking database:', error); + toast.error('Failed to check database status'); + } finally { + setLoadingAction(null); + } + }; + + const setupDatabase = async () => { + setLoadingAction('setup'); + try { + const response = await fetch('/api/jetshare/setup-db', { + method: 'POST', + }); + const data = await response.json(); + + if (data.success) { + toast.success('Database setup completed successfully'); + // Refresh database status + checkDatabase(); + } else { + if (data.sql) { + setSqlScript(data.sql); + toast.error('Please run the SQL script manually'); + } else { + toast.error(data.message || 'Database setup failed'); + } + } + } catch (error) { + console.error('Error setting up database:', error); + toast.error('Failed to setup database'); + } finally { + setLoadingAction(null); + } + }; + + const seedData = async () => { + setLoadingAction('seed'); + try { + const response = await fetch('/api/jetshare/seed', { + method: 'POST', + }); + const data = await response.json(); + + if (response.ok) { + toast.success(`Created ${data.offers?.length || 0} sample offers`); + } else { + toast.error(data.message || 'Failed to seed sample data'); + } + } catch (error) { + console.error('Error seeding data:', error); + toast.error('Failed to seed sample data'); + } finally { + setLoadingAction(null); + } + }; + + return ( + + + + + JetShare Development Helpers + + + These tools are only available in development mode and help troubleshoot JetShare functionality + + + + + + Database + Sample Data + SQL Scripts + + + +
+
+ + + +
+ + {dbStatus && ( + +
+ {dbStatus.success ? ( + + ) : ( + + )} + + {dbStatus.success ? 'Database is healthy' : 'Database issues detected'} + +
+ + {dbStatus.message || dbStatus.error || 'Database check completed'} + + {dbStatus.dbStatus && ( +
+
+ Function + {dbStatus.dbStatus.function_exists ? 'Available' : 'Missing'} +
+ + {typeof dbStatus.dbStatus.offers_count !== 'undefined' && ( +
+ Offers + {dbStatus.dbStatus.offers_count} +
+ )} + + {typeof dbStatus.dbStatus.profiles_count !== 'undefined' && ( +
+ Profiles + {dbStatus.dbStatus.profiles_count} +
+ )} +
+ )} + + {dbStatus.instructions && ( +
+ {dbStatus.instructions} +
+ )} +
+
+ )} +
+
+ + +
+
+ +
+ + + Sample Data Tools + + Generate sample flight share offers to test the JetShare UI. + These offers will be associated with your user account. + + +
+
+ + +
+ {sqlScript ? ( +
+ + + Manual SQL Required + + The API couldn't execute this SQL directly. Please copy and run this script in the Supabase SQL editor. + + + +
+ {sqlScript} +
+
+ ) : ( + + SQL Scripts + + If database setup fails, the required SQL script will be displayed here for manual execution. + + + )} +
+
+
+
+ + These tools are only shown in development mode (NODE_ENV=development) + +
+ ); +} \ No newline at end of file diff --git a/app/gdyup/components/GdyupHeader.tsx b/app/gdyup/components/GdyupHeader.tsx new file mode 100644 index 00000000..920349d3 --- /dev/null +++ b/app/gdyup/components/GdyupHeader.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { + Home, + Menu, + X, + Search, + PlaneTakeoff, + BarChart4, + LogOut, + ChevronLeft, + LogIn, + User +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/components/auth-provider'; +import { cn } from '@/lib/utils'; +import { createClient } from '@/lib/supabase'; +import Image from 'next/image'; + +export default function GdyupHeader() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + const { user, loading, signOut } = useAuth(); + const [isClient, setIsClient] = useState(false); + const [hasLocalAuth, setHasLocalAuth] = useState(false); + + useEffect(() => { + setIsClient(true); + + // Check for auth in localStorage as a fallback + const checkLocalAuth = () => { + try { + const tokenData = localStorage.getItem('sb-vjhrmizwqhmafkxbmfwa-auth-token'); + const userId = localStorage.getItem('jetstream_user_id'); + + // If we have either token data or user_id stored, consider this as potential auth + setHasLocalAuth(!!(tokenData || userId)); + + // If we have token but no user in Auth provider, try to restore session + if ((tokenData || userId) && !user && !loading) { + console.log('Header: Found auth data in localStorage but no user in context - refreshing auth state'); + + // This will trigger the auth provider to try restoring the session + const refreshAuth = async () => { + try { + const supabase = createClient(); + await supabase.auth.refreshSession(); + } catch (e) { + console.warn('Header: Error refreshing session:', e); + } + }; + + refreshAuth(); + } + } catch (e) { + console.warn('Header: Error checking localStorage:', e); + setHasLocalAuth(false); + } + }; + + checkLocalAuth(); + + // Re-check authentication every 5 seconds in case it changes + // This helps when redirecting from auth page back to GDY UP + const intervalId = setInterval(checkLocalAuth, 5000); + + return () => clearInterval(intervalId); + }, [user, loading]); + + // Determine if user is authenticated - either through Auth provider or localStorage + const isAuthenticated = !!user || (!loading && isClient && hasLocalAuth); + + const isActive = (path: string) => { + return pathname === path; + }; + + // Define menu items based on authentication status + const getMenuItems = () => { + // Items available to all users + const publicItems = [ + { + name: 'Home', + path: '/gdyup', + icon: + }, + { + name: 'Listings', + path: '/gdyup/listings', + icon: + } + ]; + + // Items that require authentication + const authItems = isAuthenticated ? [ + { + name: 'Offer a Share', + path: '/gdyup/offer', + icon: + }, + { + name: 'Dashboard', + path: '/gdyup/dashboard', + icon: + } + ] : []; + + // Debug item (development only) + const devItems = process.env.NODE_ENV === 'development' ? [ + { + name: 'Debug', + path: '/gdyup/debug', + icon: DEV + } + ] : []; + + return [...publicItems, ...authItems, ...devItems]; + }; + + const menuItems = getMenuItems(); + + const handleSignOut = async () => { + try { + await signOut(); + router.push('/'); + } catch (error) { + console.error('Sign out error:', error); + } + }; + + const handleSignIn = () => { + // Redirect back to GDY UP after login with current path + const currentPath = pathname || '/gdyup'; + const timestamp = Date.now(); // Add timestamp to avoid caching issues + router.push(`/auth/login?returnUrl=${encodeURIComponent(currentPath)}&t=${timestamp}`); + }; + + // GDY UP brand colors + const primaryColor = "#CEFF00"; + const secondaryColor = "#FF4B47"; + + return ( +
+
+
+ {/* Logo */} +
+ + GDY UP + +
+ + {/* Desktop Navigation */} + + + {/* Mobile Menu Button */} + +
+ + {/* Mobile Navigation */} + {mobileMenuOpen && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/app/gdyup/components/JetSeatVisualizer.md b/app/gdyup/components/JetSeatVisualizer.md new file mode 100644 index 00000000..82b85f54 --- /dev/null +++ b/app/gdyup/components/JetSeatVisualizer.md @@ -0,0 +1,152 @@ +# JetSeatVisualizer Component Documentation + +The `JetSeatVisualizer` component provides an interactive way for users to visualize and configure seat splits for jet sharing offers. This component allows users to specify which seats they want to share by creating either a horizontal (front/back) or vertical (left/right) split. + +## Features + +- **Dynamic Seat Layout**: Renders a seat grid based on the selected jet model +- **Interactive Split Adjustment**: Draggable crosshair interface for intuitive split configuration +- **Real-time Feedback**: Visual highlighting of which seats belong to each section +- **Embedding-Ready Data**: Outputs structured JSON for AI and embedding systems +- **Touch-Enabled**: Designed for both desktop and mobile interactions +- **AI Concierge Integration**: Exposes methods for programmatic control through the AI Concierge + +## Basic Usage + +```tsx +import JetSeatVisualizer, { SplitConfiguration } from './JetSeatVisualizer'; + +function MyComponent() { + // Handle configuration changes + const handleSplitConfigurationChange = (config: SplitConfiguration) => { + console.log('New configuration:', config); + // Save to form state or send to API + }; + + return ( + + ); +} +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `jetId` | string | Identifier for the jet model to visualize | +| `defaultLayout` | SeatLayout (optional) | Default seat layout if jet data can't be fetched | +| `onChange` | function (optional) | Callback when configuration changes | +| `initialSplit` | SplitConfiguration (optional) | Initial split configuration | +| `readOnly` | boolean (optional) | Whether the visualizer is interactive or just for display | +| `className` | string (optional) | Additional CSS classes | + +## Using with the AI Concierge + +The JetSeatVisualizer component is designed to be programmatically controlled by the AI Concierge system using refs. This allows the Concierge to open the visualizer during a conversation when a user needs to configure seat splits. + +### Invoking from the Concierge + +```tsx +import { useRef } from 'react'; +import JetSeatVisualizer, { JetSeatVisualizerRef } from './JetSeatVisualizer'; + +function ConciergeChat() { + // Create a ref to access visualizer methods + const visualizerRef = useRef(null); + + // Function to be called from the AI Concierge + const handleConciergeRequest = (action: string) => { + if (action === 'open-seat-visualizer') { + visualizerRef.current?.openVisualizer(); + } else if (action === 'close-seat-visualizer') { + visualizerRef.current?.closeVisualizer(); + } + }; + + return ( +
+ {/* Chat interface */} +
+ {/* Chat messages */} +
+ + {/* Seat visualizer (hidden by default) */} + { + // When user configures seats, AI can respond to the configuration + sendMessageToAI({ + type: 'seat-configuration', + data: config + }); + }} + /> +
+ ); +} +``` + +### AI Concierge Prompting + +When integrating with the AI Concierge, use these prompt examples to help the AI understand and describe seat configurations: + +**For describing configurations:** + +``` +The jet has a {splitOrientation} split with a {splitRatio} ratio. +This means there are {front.length} seats in the front section and {back.length} seats in the back section. +``` + +**For suggesting the visualizer:** + +``` +I can help you visualize this better. Would you like me to open the seat configuration tool so you can see exactly how the seats are arranged? +``` + +## Data Structure + +The seat configuration is stored in a JSON-friendly format that's compatible with vector embeddings: + +```json +{ + "jetId": "gulfstream-g650", + "splitOrientation": "horizontal", + "splitRatio": "50/50", + "allocatedSeats": { + "front": ["A1", "A2", "B1", "B2"], + "back": ["C1", "C2", "D1", "D2"] + } +} +``` + +This structure enables several powerful features: + +- AI Concierge can describe the configuration conversationally +- Configuration can be embedded and used for intelligent matching +- Users can understand exactly which seats they're sharing + +## Error Handling + +The component handles various error states gracefully: + +- If jet data cannot be fetched, it will use default values +- If layout information is incomplete, it provides reasonable fallbacks +- The component displays appropriate error messages when needed + +## Best Practices + +1. **Default to horizontal splits** for typical front/back cabin sharing scenarios +2. **Use clear visual feedback** to show which seats are in which section +3. **Persist the configuration** with the offer for consistent display +4. **Allow the AI Concierge** to describe configurations conversationally + +## Future Enhancements + +- Support for custom seat layouts beyond the standard grid +- Enhanced visualization of luxury jet configurations +- Mobile gesture optimizations for drag interactions +- Direct integration with specific jet model specifications diff --git a/app/gdyup/components/JetSeatVisualizer.tsx b/app/gdyup/components/JetSeatVisualizer.tsx new file mode 100644 index 00000000..e55917f3 --- /dev/null +++ b/app/gdyup/components/JetSeatVisualizer.tsx @@ -0,0 +1,853 @@ +'use client'; + +import { useState, useEffect, forwardRef, useImperativeHandle, useRef, useCallback } from 'react'; +import Selecto from 'react-selecto'; +import { cn } from '@/lib/utils'; +import { Slider } from '@/components/ui/slider'; +import { Badge } from '@/components/ui/badge'; + +// Seat types and layout interfaces +export interface SeatLayout { + rows: number; + seatsPerRow: number; + layoutType: 'standard' | 'luxury' | 'custom'; + totalSeats?: number; + seatMap?: { + skipPositions?: number[][]; + customPositions?: { row: number; col: number; id: string }[]; + }; +} + +export interface SeatConfiguration { + jet_id: string; + selectedSeats: string[]; + totalSeats: number; + totalSelected: number; + selectionPercentage: number; +} + +// Props interface +export interface JetSeatVisualizerProps { + jet_id: string; + defaultLayout?: SeatLayout; + onChange?: (config: SeatConfiguration) => void; + initialSelection?: SeatConfiguration; + readOnly?: boolean; + className?: string; + showControls?: boolean; + totalSeats?: number; + onError?: (error: Error | string) => void; + showLegend?: boolean; + showSummary?: boolean; + customLayout?: SeatLayout; + forceExactLayout?: boolean; +} + +// Export the component ref type for external usage +export type JetSeatVisualizerRef = { + openVisualizer: () => void; + closeVisualizer: () => void; + getLayoutInfo: () => { + totalSeats: number; + rows: number; + seatsPerRow: number; + layoutType: string; + jet_id: string; + }; + selectSeats: (seatIds: string[]) => void; + clearSelection: () => void; + setSelectionMode: (mode: 'tap' | 'drag') => void; +}; + +// Helper function to generate seat IDs +const generateSeatId = (row: number, col: number) => { + const rowLetter = String.fromCharCode(65 + row); // A, B, C, etc. + return `${rowLetter}${col + 1}`; +}; + +// Add a status message component +const StatusMessage = ({ isLoading, error, onRetry }: { isLoading: boolean; error: string | null; onRetry?: () => void }) => { + if (isLoading) { + return ( +
+
+

Loading jet configuration...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ); + } + + return null; +}; + +// Add a seat selection summary component +const SeatSelectionSummary = ({ + selectedSeats, + totalSeats, +}: { + selectedSeats: string[]; + totalSeats: number; +}) => { + const selectionPercentage = totalSeats > 0 ? Math.round((selectedSeats.length / totalSeats) * 100) : 0; + + return ( +
+
+ Seat Selection Summary + + Total Seats: {totalSeats} + +
+ +
+
+ Selected Seats +
+ {selectedSeats.length} + {selectionPercentage}% +
+
+ +
+ Remaining Seats +
+ {totalSeats - selectedSeats.length} + {100 - selectionPercentage}% +
+
+
+ + {selectedSeats.length > 0 && ( +
+ {selectedSeats.map(seat => ( + + {seat} + + ))} +
+ )} +
+ ); +}; + +// Main component implementation +const JetSeatVisualizer = forwardRef( + ({ + jet_id, + defaultLayout, + onChange, + initialSelection, + readOnly = false, + className, + showControls = true, + totalSeats, + onError, + showLegend = true, + showSummary = true, + customLayout, + forceExactLayout + }, ref) => { + // Default layout if none provided + const [layout, setLayout] = useState( + defaultLayout || { rows: 6, seatsPerRow: 4, layoutType: 'standard' } + ); + + // State for loading layout data + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // State for visibility + const [isVisible, setIsVisible] = useState(true); + + // State for seat selection + const [selectedSeats, setSelectedSeats] = useState( + initialSelection?.selectedSeats || [] + ); + + // Selection mode - default (tap) or drag + const [selectionMode, setSelectionMode] = useState<'tap' | 'drag'>('tap'); + + // State for grid calculations + const [gridDimensions, setGridDimensions] = useState({ width: 0, height: 0 }); + const [seatSize, setSeatSize] = useState(0); + + // Add state for aisle display + const [showAisle, setShowAisle] = useState(false); + + // Flag for preventing update loops + const isUpdatingRef = useRef(false); + + // Refs for seats + const seatsRef = useRef([]); + const selectoRef = useRef(null); + const containerRef = useRef(null); + + // Add state to track seats that should be skipped (not displayed) + const [skipPositions, setSkipPositions] = useState([]); + + // Calculate seat allocation + const calculateSeatConfiguration = useCallback((): SeatConfiguration => { + const actualTotalSeats = layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length); + const selectionPercentage = actualTotalSeats > 0 ? Math.round((selectedSeats.length / actualTotalSeats) * 100) : 0; + + return { + jet_id, + selectedSeats, + totalSeats: actualTotalSeats, + totalSelected: selectedSeats.length, + selectionPercentage + }; + }, [jet_id, layout, selectedSeats, skipPositions]); + + // Function to check if a position should be skipped + const isSkippedPosition = useCallback((row: number, col: number): boolean => { + return skipPositions.some(pos => pos[0] === row && pos[1] === col); + }, [skipPositions]); + + // Update parent component with selection changes + const updateParentComponent = useCallback(() => { + if (!onChange || isUpdatingRef.current) return; + + // Call the onChange callback with the current configuration + onChange(calculateSeatConfiguration()); + }, [onChange, calculateSeatConfiguration]); + + // Expose methods via ref + useImperativeHandle(ref, () => ({ + openVisualizer: () => setIsVisible(true), + closeVisualizer: () => setIsVisible(false), + getLayoutInfo: () => { + const totalSeats = layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length); + return { + totalSeats, + rows: layout.rows, + seatsPerRow: layout.seatsPerRow, + layoutType: layout.layoutType, + jet_id: jet_id + }; + }, + selectSeats: (seatIds: string[]) => { + setSelectedSeats(seatIds); + }, + clearSelection: () => { + setSelectedSeats([]); + }, + setSelectionMode: (mode: 'tap' | 'drag') => { + setSelectionMode(mode); + } + })); + + // Add debug logging - memoize to prevent dependency changes + const debugLog = useCallback((message: string, data?: any) => { + if (process.env.NODE_ENV === 'development') { + console.log(`[JetSeatVisualizer] ${message}`, data || ''); + } + }, []); + + // Handle seat click + const handleSeatClick = useCallback((seatId: string) => { + if (readOnly || selectionMode !== 'tap') return; + + // Use functional state update to prevent stale state issues + setSelectedSeats(prev => { + const isSelected = prev.includes(seatId); + return isSelected + ? prev.filter(id => id !== seatId) // Remove if already selected + : [...prev, seatId]; // Add if not selected + }); + }, [readOnly, selectionMode]); + + // Handle select all + const handleSelectAll = () => { + if (readOnly) return; + + // Generate all valid seat IDs (excluding skipped positions) + const allSeatIds: string[] = []; + + for (let row = 0; row < layout.rows; row++) { + for (let col = 0; col < layout.seatsPerRow; col++) { + if (!isSkippedPosition(row, col)) { + allSeatIds.push(generateSeatId(row, col)); + } + } + } + + setSelectedSeats(allSeatIds); + }; + + // Handle clear selection + const handleClearSelection = () => { + if (readOnly) return; + setSelectedSeats([]); + }; + + // Toggle selection mode + const toggleSelectionMode = () => { + if (readOnly) return; + setSelectionMode(prev => prev === 'tap' ? 'drag' : 'tap'); + }; + + // Handle selecto selection + const handleSelectoSelect = useCallback((e: { selected: (HTMLElement | SVGElement)[] }) => { + if (readOnly || selectionMode !== 'drag') return; + + // Get selected elements + const selectedElements = e.selected; + + // Extract seat IDs from selected elements + const newSelectedSeats = selectedElements.map((el) => + el instanceof HTMLElement ? el.getAttribute('data-seat-id') : null + ).filter((id: string | null): id is string => id !== null); + + // Update selected seats - use functional state update + setSelectedSeats(prev => { + // Create a Set for efficient lookups + const prevSet = new Set(prev); + const newSeatsToAdd = newSelectedSeats.filter(id => !prevSet.has(id)); + + // Return new array with unique values + return [...prev, ...newSeatsToAdd]; + }); + }, [readOnly, selectionMode]); + + // Fetch layout data when jet_id changes + useEffect(() => { + const fetchLayoutData = async () => { + if (!jet_id) return; + + debugLog(`Fetching layout for jet ID: ${jet_id}`); + setIsLoading(true); + setError(null); + + try { + // Call API to get jet layout data + const response = await fetch(`/api/jets/${jet_id}`); + + if (!response.ok) { + const errorMsg = `Failed to fetch jet layout data: ${response.status} ${response.statusText}`; + throw new Error(errorMsg); + } + + const data = await response.json(); + debugLog('Received jet layout data:', data); + + if (data.seatLayout) { + setLayout(data.seatLayout); + debugLog('Applied layout:', data.seatLayout); + + // Save skip positions if available + if (data.seatLayout.seatMap?.skipPositions) { + setSkipPositions(data.seatLayout.seatMap.skipPositions); + debugLog('Applied skip positions:', data.seatLayout.seatMap.skipPositions); + } else { + setSkipPositions([]); + } + } + } catch (err) { + console.error('Error fetching jet layout:', err); + setError('Failed to load jet configuration'); + + // Call onError prop if provided + if (onError) { + onError(err instanceof Error ? err : String(err)); + } + + // Keep using the default layout + // Create a fallback layout with the correct type + const fallbackLayout: SeatLayout = { + rows: 4, + seatsPerRow: 3, + layoutType: 'standard' as const, + totalSeats: totalSeats || 12 + }; + + setLayout(fallbackLayout); + debugLog('Using fallback layout:', fallbackLayout); + } finally { + setIsLoading(false); + } + }; + + fetchLayoutData(); + + // Expose fetchLayoutData for retrying + (window as any).fetchLayoutData = fetchLayoutData; + + return () => { + // Cleanup + delete (window as any).fetchLayoutData; + }; + }, [jet_id, debugLog, onError, totalSeats]); + + // Update dimensions when layout changes or component mounts + useEffect(() => { + // Set grid dimensions based on the number of rows/columns + const baseGridWidth = layout.seatsPerRow * 60; // 60px per seat for better touch targets + const baseGridHeight = layout.rows * 60; // 60px per seat + + setGridDimensions({ + width: baseGridWidth, + height: baseGridHeight, + }); + + // Calculate seat size + setSeatSize(60); + + // Log for debugging + debugLog('Updated grid dimensions:', { width: baseGridWidth, height: baseGridHeight, rows: layout.rows, seatsPerRow: layout.seatsPerRow }); + }, [layout.rows, layout.seatsPerRow, debugLog]); + + // Initialize with initial selection if provided + useEffect(() => { + if (!initialSelection || isUpdatingRef.current) return; + + isUpdatingRef.current = true; + + setSelectedSeats(initialSelection.selectedSeats || []); + + // Reset the flag after a short delay + const timer = setTimeout(() => { + isUpdatingRef.current = false; + }, 50); + + return () => clearTimeout(timer); + }, [initialSelection]); // Only dependency is initialSelection + + // Update when selection changes + useEffect(() => { + if (isUpdatingRef.current) return; + + // Use setTimeout to debounce updates and break potential update cycles + const timer = setTimeout(() => { + updateParentComponent(); + }, 100); // Add a small debounce delay + + return () => clearTimeout(timer); + }, [selectedSeats, updateParentComponent]); + + // Update total seats from props if provided + useEffect(() => { + if (customLayout && forceExactLayout) { + // Use the exact layout specified in customLayout prop + setLayout(customLayout); + debugLog('Using custom layout:', customLayout); + + // Apply skip positions if provided + if (customLayout.seatMap?.skipPositions) { + setSkipPositions(customLayout.seatMap.skipPositions); + debugLog('Applied custom skip positions:', customLayout.seatMap.skipPositions); + } else { + setSkipPositions([]); + } + + // Calculate grid dimensions based on the custom layout + const baseGridWidth = customLayout.seatsPerRow * 60; + const baseGridHeight = customLayout.rows * 60; + + setGridDimensions({ + width: baseGridWidth, + height: baseGridHeight, + }); + + setSeatSize(60); + } else if (totalSeats && totalSeats > 0) { + // Calculate rows and columns based on total seats + // For simplicity, we'll make a grid with approximately square dimensions + const approxDimension = Math.ceil(Math.sqrt(totalSeats)); + + setLayout(prev => ({ + ...prev, + rows: Math.ceil(totalSeats / approxDimension), + seatsPerRow: Math.min(approxDimension, totalSeats), + totalSeats: totalSeats // Explicitly set the totalSeats property + })); + + // Reset skip positions since we're using the auto-calculated layout + setSkipPositions([]); + + // Log the change for debugging + debugLog(`Updated layout with total seats: ${totalSeats}`, { + rows: Math.ceil(totalSeats / approxDimension), + seatsPerRow: Math.min(approxDimension, totalSeats) + }); + } + }, [totalSeats, customLayout, forceExactLayout, debugLog]); + + // Add a new method to fetch jet interior details + const fetchSeatLayout = useCallback(async () => { + if (!jet_id) { + console.error('No jet ID provided'); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Fetch seat layout from API + const response = await fetch(`/api/jets/${jet_id}`); + + if (!response.ok) { + throw new Error(`Failed to fetch layout: ${response.status}`); + } + + const data = await response.json(); + + // Store the jet data - now including interiors if available + setLayout(data.seatLayout || { + rows: 4, + seatsPerRow: 3, + layoutType: 'standard' as const, + totalSeats: 12 + }); + + // Try to fetch interior details if we have a proper UUID + if (jet_id && jet_id.includes('-') && jet_id.length > 10) { + try { + const interiorResponse = await fetch(`/api/jetshare/getJetInterior?jetId=${jet_id}`); + if (interiorResponse.ok) { + const interiorData = await interiorResponse.json(); + if (interiorData && interiorData.interior) { + // Update layout with interior information + setLayout(prev => ({ + ...prev, + interior: interiorData.interior + })); + + // If interior has seat count, use that + if (interiorData.interior.seats) { + layout.totalSeats = parseInt(interiorData.interior.seats); + + // Try to create a better layout based on seat count + if (layout.totalSeats <= 8) { + layout.rows = 2; + layout.seatsPerRow = 4; + } else if (layout.totalSeats <= 12) { + layout.rows = 3; + layout.seatsPerRow = 4; + } else if (layout.totalSeats <= 16) { + layout.rows = 4; + layout.seatsPerRow = 4; + } else { + layout.rows = 5; + layout.seatsPerRow = 4; + } + } + } + } + } catch (interiorError) { + console.warn('Error fetching jet interior:', interiorError); + // Continue with default layout - don't fail the whole component + } + } + + // Calculate grid dimensions based on layout + const baseGridWidth = layout.seatsPerRow * 60; // 60px per seat for better touch targets + const baseGridHeight = layout.rows * 60; // 60px per seat + + setGridDimensions({ + width: baseGridWidth, + height: baseGridHeight, + }); + + // Calculate seat size + setSeatSize(60); + + // If initial selection is provided, set it + if (initialSelection && initialSelection.selectedSeats) { + setSelectedSeats(initialSelection.selectedSeats); + } + + // Auto-open the visualizer if it should be open by default + setIsVisible(true); + + // Success! + setIsLoading(false); + } catch (err) { + console.error('Error fetching seat layout:', err); + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + + if (onError) { + onError(err instanceof Error ? err : String(err)); + } + + // Even on error, we set a default layout so the visualizer can still function + const defaultLayout: SeatLayout = { + rows: 4, + seatsPerRow: 3, + layoutType: 'standard' as const, + totalSeats: 12 + }; + + setLayout(defaultLayout); + setGridDimensions({ width: defaultLayout.seatsPerRow * 60, height: defaultLayout.rows * 60 }); + setSeatSize(60); + setSelectedSeats([]); + } + }, [jet_id, initialSelection, onError]); + + if (!isVisible) return null; + + return ( +
+ {showControls && ( +
+

Seat Selection

+ + {/* Seat selection summary */} + {!isLoading && !error && ( + + )} + + {/* Selection mode toggles */} +
+ + +
+ + {/* Action buttons */} +
+ + +
+
+ )} + + + + {!isLoading && !error && ( +
+ {/* Selection summary - Improved contrast - now conditionally rendered */} + {showSummary !== false && ( +
+
+
+ {selectedSeats.length} + of + {totalSeats} + seats selected +
+
+ )} + + {/* Improved legend with better contrast - now conditionally rendered */} + {showLegend !== false && ( +
+
+
+
+ Selected +
+
+
+ Available +
+
+
+ Unavailable +
+
+
+ )} + + {/* Seat map container with layout info - more mobile optimized */} +
+ {/* Cabin representation */} +
+ + {/* Aisle indicator */} +
+ + {/* Simplified layout info bar at the top */} +
+
{layout.rows} ร— {layout.seatsPerRow}
+
+ {selectedSeats.length} of {layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length)} selected +
+
+ + {/* Seats grid - more compact with reduced spacing */} +
+ {Array.from({ length: layout.rows }).map((_, rowIdx) => + Array.from({ length: layout.seatsPerRow }).map((_, colIdx) => { + // Skip rendering this seat if it's in skipPositions + if (isSkippedPosition(rowIdx, colIdx)) { + return
; + } + + const seatId = generateSeatId(rowIdx, colIdx); + const isSelected = selectedSeats.includes(seatId); + + // Seat styling with improved contrast and visibility + return ( +
{ + if (el) seatsRef.current[rowIdx * layout.seatsPerRow + colIdx] = el; + }} + data-seat-id={seatId} + className={cn( + // Base styling + "flex items-center justify-center rounded-md cursor-pointer touch-manipulation transition-all transform hover:scale-105", + "focus:outline-none focus:ring-2 focus:ring-blue-500", + // Dynamic classes based on state + isSelected + ? "bg-blue-600 border-2 border-blue-400 text-white shadow-md hover:bg-blue-700" + : readOnly + ? "bg-gray-800 border border-gray-700 opacity-50 text-gray-500 cursor-not-allowed" + : "bg-gray-700 border border-gray-600 text-gray-300 hover:bg-gray-600 hover:border-gray-500 hover:text-white shadow-sm", + readOnly ? "pointer-events-none" : "" + )} + onClick={() => handleSeatClick(seatId)} + style={{ + width: `${seatSize * 0.8}px`, + height: `${seatSize * 0.8}px`, + margin: `${seatSize * 0.1}px`, + fontSize: `${seatSize * 0.4}px`, + fontWeight: "bold", + }} + role="checkbox" + aria-checked={isSelected} + aria-label={`Seat ${seatId} ${isSelected ? 'Selected' : (readOnly ? 'Unavailable' : 'Available')}`} + > + {seatId} +
+ ); + }) + )} +
+ + {/* Selecto component for drag selection */} + {!readOnly && selectionMode === 'drag' && containerRef.current && ( + + )} +
+
+
+ )} + + {showControls && ( +
+ +
+ {selectedSeats.length} of {layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length)} seats selected +
+
+ )} +
+ ); + } +); + +JetSeatVisualizer.displayName = 'JetSeatVisualizer'; + +export default JetSeatVisualizer; diff --git a/app/gdyup/components/JetSeatVisualizer.tsx.bak b/app/gdyup/components/JetSeatVisualizer.tsx.bak new file mode 100644 index 00000000..3707f4aa --- /dev/null +++ b/app/gdyup/components/JetSeatVisualizer.tsx.bak @@ -0,0 +1,631 @@ +'use client'; + +import { useState, useEffect, forwardRef, useImperativeHandle, useRef, useCallback } from 'react'; +import Selecto from 'react-selecto'; +import { cn } from '@/lib/utils'; +import { Slider } from '@/components/ui/slider'; +import { Badge } from '@/components/ui/badge'; + +// Seat types and layout interfaces +export interface SeatLayout { + rows: number; + seatsPerRow: number; + layoutType: 'standard' | 'luxury' | 'custom'; + totalSeats?: number; + seatMap?: { + skipPositions?: number[][]; + customPositions?: { row: number; col: number; id: string }[]; + }; +} + +export interface SeatConfiguration { + jetId: string; + selectedSeats: string[]; + totalSeats: number; + totalSelected: number; + selectionPercentage: number; +} + +// Props interface +export interface JetSeatVisualizerProps { + jetId: string; + defaultLayout?: SeatLayout; + onChange?: (config: SeatConfiguration) => void; + initialSelection?: SeatConfiguration; + readOnly?: boolean; + className?: string; + showControls?: boolean; + totalSeats?: number; +} + +// Export the component ref type for external usage +export type JetSeatVisualizerRef = { + openVisualizer: () => void; + closeVisualizer: () => void; + getLayoutInfo: () => { + totalSeats: number; + rows: number; + seatsPerRow: number; + layoutType: string; + jetId: string; + }; + selectSeats: (seatIds: string[]) => void; + clearSelection: () => void; +}; + +// Helper function to generate seat IDs +const generateSeatId = (row: number, col: number) => { + const rowLetter = String.fromCharCode(65 + row); // A, B, C, etc. + return `${rowLetter}${col + 1}`; +}; + +// Add a status message component +const StatusMessage = ({ isLoading, error, onRetry }: { isLoading: boolean; error: string | null; onRetry?: () => void }) => { + if (isLoading) { + return ( +
+
+

Loading jet configuration...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ); + } + + return null; +}; + +// Add a seat selection summary component +const SeatSelectionSummary = ({ + selectedSeats, + totalSeats, +}: { + selectedSeats: string[]; + totalSeats: number; +}) => { + const selectionPercentage = totalSeats > 0 ? Math.round((selectedSeats.length / totalSeats) * 100) : 0; + + return ( +
+
+ Seat Selection Summary + + Total Seats: {totalSeats} + +
+ +
+
+ Selected Seats + {selectedSeats.length} seats + {selectionPercentage}% +
+ +
+ Remaining Seats + {totalSeats - selectedSeats.length} seats + {100 - selectionPercentage}% +
+
+ + {selectedSeats.length > 0 && ( +
+ {selectedSeats.map(seat => ( + + {seat} + + ))} +
+ )} +
+ ); +}; + +// Main component implementation +const JetSeatVisualizer = forwardRef( + ({ + jetId, + defaultLayout, + onChange, + initialSelection, + readOnly = false, + className, + showControls = true, + totalSeats + }, ref) => { + // Default layout if none provided + const [layout, setLayout] = useState( + defaultLayout || { rows: 6, seatsPerRow: 4, layoutType: 'standard' } + ); + + // State for loading layout data + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // State for visibility + const [isVisible, setIsVisible] = useState(true); + + // State for seat selection + const [selectedSeats, setSelectedSeats] = useState( + initialSelection?.selectedSeats || [] + ); + + // Selection mode - default (tap) or drag + const [selectionMode, setSelectionMode] = useState<'tap' | 'drag'>('tap'); + + // State for grid calculations + const [gridDimensions, setGridDimensions] = useState({ width: 0, height: 0 }); + const [seatSize, setSeatSize] = useState(0); + + // Flag for preventing update loops + const isUpdatingRef = useRef(false); + + // Refs for seats + const seatsRef = useRef([]); + const selectoRef = useRef(null); + const containerRef = useRef(null); + + // Add state to track seats that should be skipped (not displayed) + const [skipPositions, setSkipPositions] = useState([]); + + // Calculate seat allocation + const calculateSeatConfiguration = useCallback((): SeatConfiguration => { + const actualTotalSeats = layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length); + const selectionPercentage = actualTotalSeats > 0 ? (selectedSeats.length / actualTotalSeats) * 100 : 0; + + return { + jetId, + selectedSeats, + totalSeats: actualTotalSeats, + totalSelected: selectedSeats.length, + selectionPercentage + }; + }, [jetId, layout, selectedSeats, skipPositions]); + + // Function to check if a position should be skipped + const isSkippedPosition = useCallback((row: number, col: number): boolean => { + return skipPositions.some(pos => pos[0] === row && pos[1] === col); + }, [skipPositions]); + + // Update parent component with selection changes + const updateParentComponent = useCallback(() => { + if (!onChange || isUpdatingRef.current) return; + + // Call the onChange callback with the current configuration + onChange(calculateSeatConfiguration()); + }, [onChange, calculateSeatConfiguration]); + + // Expose methods via ref + useImperativeHandle(ref, () => ({ + openVisualizer: () => setIsVisible(true), + closeVisualizer: () => setIsVisible(false), + getLayoutInfo: () => { + const totalSeats = layout.totalSeats || (layout.rows * layout.seatsPerRow - skipPositions.length); + return { + totalSeats, + rows: layout.rows, + seatsPerRow: layout.seatsPerRow, + layoutType: layout.layoutType, + jetId: jetId + }; + }, + selectSeats: (seatIds: string[]) => { + setSelectedSeats(seatIds); + }, + clearSelection: () => { + setSelectedSeats([]); + } + })); + + // Add debug logging + const debugLog = (message: string, data?: any) => { + if (process.env.NODE_ENV === 'development') { + console.log(`[JetSeatVisualizer] ${message}`, data || ''); + } + }; + + // Handle seat click + const handleSeatClick = useCallback((seatId: string) => { + if (readOnly || selectionMode !== 'tap') return; + + setSelectedSeats(prev => { + const isSelected = prev.includes(seatId); + return isSelected + ? prev.filter(id => id !== seatId) // Remove if already selected + : [...prev, seatId]; // Add if not selected + }); + }, [readOnly, selectionMode]); + + // Handle select all + const handleSelectAll = () => { + if (readOnly) return; + + // Generate all valid seat IDs (excluding skipped positions) + const allSeatIds: string[] = []; + + for (let row = 0; row < layout.rows; row++) { + for (let col = 0; col < layout.seatsPerRow; col++) { + if (!isSkippedPosition(row, col)) { + allSeatIds.push(generateSeatId(row, col)); + } + } + } + + setSelectedSeats(allSeatIds); + }; + + // Handle clear selection + const handleClearSelection = () => { + if (readOnly) return; + setSelectedSeats([]); + }; + + // Toggle selection mode + const toggleSelectionMode = () => { + if (readOnly) return; + setSelectionMode(prev => prev === 'tap' ? 'drag' : 'tap'); + }; + + // Handle selecto selection + const handleSelectoSelect = useCallback((e: { selected: (HTMLElement | SVGElement)[] }) => { + if (readOnly || selectionMode !== 'drag') return; + + // Get selected elements + const selectedElements = e.selected; + + // Extract seat IDs from selected elements + const newSelectedSeats = selectedElements.map((el) => + el instanceof HTMLElement ? el.getAttribute('data-seat-id') : null + ).filter((id: string | null): id is string => id !== null); + + // Update selected seats + setSelectedSeats(prev => { + // For toggle behavior: Remove seats that were already selected + const uniqueNewSeats = newSelectedSeats.filter(id => !prev.includes(id)); + return [...prev, ...uniqueNewSeats]; + }); + }, [readOnly, selectionMode]); + + // Fetch layout data when jetId changes + useEffect(() => { + const fetchLayoutData = async () => { + if (!jetId) return; + + debugLog(`Fetching layout for jet ID: ${jetId}`); + setIsLoading(true); + setError(null); + + try { + // Call API to get jet layout data + const response = await fetch(`/api/jets/${jetId}`); + + if (!response.ok) { + throw new Error('Failed to fetch jet layout data'); + } + + const data = await response.json(); + debugLog('Received jet layout data:', data); + + if (data.seatLayout) { + setLayout(data.seatLayout); + debugLog('Applied layout:', data.seatLayout); + + // Save skip positions if available + if (data.seatLayout.seatMap?.skipPositions) { + setSkipPositions(data.seatLayout.seatMap.skipPositions); + debugLog('Applied skip positions:', data.seatLayout.seatMap.skipPositions); + } else { + setSkipPositions([]); + } + } + } catch (err) { + console.error('Error fetching jet layout:', err); + setError('Failed to load jet configuration'); + // Keep using the default layout + } finally { + setIsLoading(false); + } + }; + + fetchLayoutData(); + + // Expose fetchLayoutData for retrying + (window as any).fetchLayoutData = fetchLayoutData; + + return () => { + // Cleanup + delete (window as any).fetchLayoutData; + }; + }, [jetId]); + + // Update dimensions when layout changes or component mounts + useEffect(() => { + // Set grid dimensions based on the number of rows/columns + // These are base values that will be scaled by the container + const baseGridWidth = layout.seatsPerRow * 50; // 50px per seat for better touch targets + const baseGridHeight = layout.rows * 50; // 50px per seat + + setGridDimensions({ + width: baseGridWidth, + height: baseGridHeight, + }); + + // Calculate seat size + setSeatSize(50); + }, [layout]); + + // Initialize with initial selection if provided + useEffect(() => { + if (!initialSelection || isUpdatingRef.current) return; + + isUpdatingRef.current = true; + + setSelectedSeats(initialSelection.selectedSeats || []); + + setTimeout(() => { + isUpdatingRef.current = false; + }, 50); + }, [initialSelection]); + + // Update when selection changes + useEffect(() => { + if (isUpdatingRef.current) return; + + // Use setTimeout to debounce updates and break potential update cycles + const timer = setTimeout(() => { + updateParentComponent(); + }, 100); // Add a small debounce delay + + return () => clearTimeout(timer); + }, [selectedSeats, updateParentComponent]); + + // Update total seats from props if provided + useEffect(() => { + if (totalSeats && totalSeats > 0) { + // Calculate rows and columns based on total seats + // For simplicity, we'll make a grid with approximately square dimensions + const approxDimension = Math.ceil(Math.sqrt(totalSeats)); + + setLayout(prev => ({ + ...prev, + rows: Math.ceil(totalSeats / approxDimension), + seatsPerRow: Math.min(approxDimension, totalSeats) + })); + } + }, [totalSeats]); + + if (!isVisible) return null; + + return ( +
+ {showControls && ( +
+

Seat Selection

+ +
+ + +
+ +
+ + +
+ + {/* Show seat selection summary */} + {!isLoading && !error && ( + + )} +
+ )} + + { + setIsLoading(true); + setError(null); + // Use the function from the current effect scope + const fetchJetData = async () => { + if (!jetId) return; + try { + const response = await fetch(`/api/jets/${jetId}`); + if (!response.ok) throw new Error('Failed to fetch jet layout data'); + const data = await response.json(); + if (data.seatLayout) { + setLayout(data.seatLayout); + if (data.seatLayout.seatMap?.skipPositions) { + setSkipPositions(data.seatLayout.seatMap.skipPositions); + } else { + setSkipPositions([]); + } + } + } catch (err) { + console.error('Error fetching jet layout:', err); + setError('Failed to load jet configuration'); + } finally { + setIsLoading(false); + } + }; + fetchJetData(); + }} + /> + + {!isLoading && !error && ( +
+ {/* Show jet model and configuration info */} +
+
+ {layout.layoutType === 'luxury' ? 'Luxury Layout' : 'Standard Layout'} +
+
+ {layout.rows} rows ร— {layout.seatsPerRow} seats +
+
+ + {/* Seats grid with improved styling */} +
+ {Array.from({ length: layout.rows }).map((_, rowIdx) => + Array.from({ length: layout.seatsPerRow }).map((_, colIdx) => { + // Skip rendering this seat if it's in skipPositions + if (isSkippedPosition(rowIdx, colIdx)) { + return
; + } + + const seatId = generateSeatId(rowIdx, colIdx); + const isSelected = selectedSeats.includes(seatId); + + // Improved seat styling with touch-friendly size + return ( +
{ + if (el) seatsRef.current[rowIdx * layout.seatsPerRow + colIdx] = el; + }} + data-seat-id={seatId} + className={cn( + "m-1 flex items-center justify-center rounded-lg transition-colors duration-200 cursor-pointer touch-manipulation", + isSelected + ? "bg-blue-500 text-white border border-blue-600 dark:bg-blue-600 dark:text-blue-50 dark:border-blue-700" + : "bg-gray-100 text-gray-900 border border-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700", + layout.layoutType === 'luxury' ? "shadow-sm" : "", + readOnly ? "pointer-events-none" : "" + )} + onClick={() => handleSeatClick(seatId)} + style={{ minWidth: '40px', minHeight: '40px' }} // Ensure good touch target size + role="checkbox" + aria-checked={isSelected} + aria-label={`Seat ${seatId}`} + > + {seatId} +
+ ); + }) + )} +
+ + {/* Selecto component for drag selection */} + {!readOnly && selectionMode === 'drag' && ( + + )} +
+ )} + + {showControls && ( +
+ +
+ {selectionMode === 'tap' + ? 'Tap on seats to select/deselect' + : 'Drag to select multiple seats'} +
+
+ )} +
+ ); + } +); + +JetSeatVisualizer.displayName = 'JetSeatVisualizer'; + +export default JetSeatVisualizer; +export default JetSeatVisualizer; \ No newline at end of file diff --git a/app/gdyup/components/JetSelector.tsx b/app/gdyup/components/JetSelector.tsx new file mode 100644 index 00000000..d5137901 --- /dev/null +++ b/app/gdyup/components/JetSelector.tsx @@ -0,0 +1,601 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Image from 'next/image'; +import { Check, ChevronsUpDown, Loader2, Search, Plane, ChevronDown } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Combobox } from '@headlessui/react'; +import { createPortal } from 'react-dom'; +import { Skeleton } from '@/components/ui/skeleton'; + +// Types for jet data +interface Jet { + id: string; + manufacturer: string; + model: string; + tail_number: string; + capacity: number; + range_nm?: number; + cruise_speed_kts?: number; + image_url?: string; + thumbnail_url?: string; + description?: string; + is_popular?: boolean; + display_name?: string; + year?: number; +} + +// Server-friendly props interface +export interface JetSelectorProps { + value: string; + disabled?: boolean; + className?: string; + id?: string; + onChangeValue?: string; // Serializable placeholder + onChangeSeatCapacity?: number; // Serializable placeholder + onCustomChangeValue?: string; // Serializable placeholder + onBlur?: () => void; + placeholder?: string; +} + +// Client-only props interface with function handlers +interface ClientJetSelectorProps extends Omit { + onChange: (value: string, seatCapacity?: number, jetId?: string) => void; + onCustomChange?: (value: string) => void; +} + +// The actual component implementation +function JetSelectorImpl({ + value, + onChange, + onCustomChange, + disabled = false, + className, + id, + onBlur, + placeholder = "Select aircraft model" +}: ClientJetSelectorProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [showCustomInput, setShowCustomInput] = useState(false); + const [customValue, setCustomValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [jets, setJets] = useState([]); + const [manufacturers, setManufacturers] = useState([]); + const [selectedManufacturer, setSelectedManufacturer] = useState(null); + const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + const [selectedJet, setSelectedJet] = useState(null); + const [filterByCapacity, setFilterByCapacity] = useState(null); + + // Refs for positioning dropdown correctly + const inputRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + width: 0 + }); + + // Add state for portal container + const [portalContainer, setPortalContainer] = useState(null); + + // Initialize portal container on mount + useEffect(() => { + // Check if document is available (only in browser) + if (typeof document !== 'undefined') { + // Create or find the portal container + let container = document.getElementById('dropdown-portal-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'dropdown-portal-container'; + container.style.position = 'absolute'; + container.style.top = '0'; + container.style.left = '0'; + container.style.width = '100%'; + container.style.height = '0'; + container.style.overflow = 'visible'; + container.style.zIndex = '9999'; + container.style.pointerEvents = 'none'; + document.body.appendChild(container); + } + setPortalContainer(container); + } + + // Cleanup function to remove the container when component unmounts + return () => { + if (typeof document !== 'undefined' && !document.getElementById('keep-dropdown-portal')) { + const container = document.getElementById('dropdown-portal-container'); + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + } + }; + }, []); + + // Calculate dropdown position when opened + useEffect(() => { + if (open && inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, // 4px gap + left: rect.left + window.scrollX, + width: rect.width + }); + } + }, [open]); + + // Add a function to handle errors with more detail + const logError = (msg: string, error: any) => { + console.error(`JetSelector Error - ${msg}:`, + error instanceof Error ? `${error.name}: ${error.message}` : error + ); + }; + + // Log successful jet loading + const logJetsLoaded = (count: number, source: string) => { + console.log(`JetSelector - Loaded ${count} jets successfully from ${source}`); + }; + + // Fetch jets from API + useEffect(() => { + const fetchJets = async () => { + try { + setIsLoading(true); + setError(null); + + // Add timestamp to prevent caching + const timestamp = new Date().getTime(); + const response = await fetch(`/api/jetshare/getJets?t=${timestamp}`, { + method: 'GET', + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch jets: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('Jets API response:', data); + + if (data.jets && Array.isArray(data.jets) && data.jets.length > 0) { + // Type-safe cast of the jets + const loadedJets = data.jets as Jet[]; + + // Enhance jets with display_name + const enhancedJets = loadedJets.map(jet => ({ + ...jet, + display_name: `${jet.manufacturer} ${jet.model}${jet.tail_number ? ` (${jet.tail_number})` : ''}`, + thumbnail_url: jet.image_url || `/images/jets/${jet.manufacturer.toLowerCase()}/${jet.model.toLowerCase().replace(/\s+/g, '-')}.jpg` + })); + + setJets(enhancedJets); + logJetsLoaded(enhancedJets.length, 'API'); + + // Extract unique manufacturers + const uniqueManufacturers: string[] = [...new Set( + enhancedJets.map(jet => String(jet.manufacturer)) + .filter(mfr => typeof mfr === 'string' && mfr.length > 0) + )]; + setManufacturers(uniqueManufacturers); + + // Reset retry count on success + setRetryCount(0); + } else { + logError('API returned empty or invalid jets', + { responseStatus: response.status, data } + ); + throw new Error('Invalid response format or empty jets list'); + } + } catch (error) { + logError('Error fetching jets', error); + + // Check if we should retry (up to 3 times) + if (retryCount < 3) { + setRetryCount(prev => prev + 1); + + // Wait a bit before retrying (exponential backoff) + const retryDelay = Math.pow(2, retryCount) * 500; + console.log(`Retrying jets fetch in ${retryDelay}ms...`); + + setTimeout(() => { + fetchJets(); + }, retryDelay); + return; + } + + // If all attempts fail, use fallback data + setError('Using fallback jet data - you can still select models'); + + // Provide fallback data when API fails + const fallbackJets: Jet[] = [ + { + id: 'gulfstream-g650', + manufacturer: 'Gulfstream', + model: 'G650', + tail_number: 'N1JS', + display_name: 'Gulfstream G650 (N1JS)', + capacity: 19, + is_popular: true + }, + { + id: 'bombardier-global-7500', + manufacturer: 'Bombardier', + model: 'Global 7500', + tail_number: 'N2JS', + display_name: 'Bombardier Global 7500 (N2JS)', + capacity: 19, + is_popular: true + }, + { + id: 'embraer-phenom-300e', + manufacturer: 'Embraer', + model: 'Phenom 300E', + tail_number: 'N3JS', + display_name: 'Embraer Phenom 300E (N3JS)', + capacity: 10, + is_popular: true + }, + { + id: 'cessna-citation-longitude', + manufacturer: 'Cessna', + model: 'Citation Longitude', + tail_number: 'N4JS', + display_name: 'Cessna Citation Longitude (N4JS)', + capacity: 12 + }, + { + id: 'dassault-falcon-8x', + manufacturer: 'Dassault', + model: 'Falcon 8X', + tail_number: 'N5JS', + display_name: 'Dassault Falcon 8X (N5JS)', + capacity: 16 + }, + { + id: 'other-custom', + manufacturer: 'Other', + model: 'Custom', + tail_number: '', + display_name: 'Other (Custom Aircraft)', + capacity: 8 + } + ]; + + setJets(fallbackJets); + // Extract manufacturers as string array + const fallbackManufacturers: string[] = [...new Set( + fallbackJets.map(jet => jet.manufacturer) + )]; + setManufacturers(fallbackManufacturers); + } finally { + setIsLoading(false); + } + }; + + fetchJets(); + }, []); + + // Check if current value is "Other" and show custom input + useEffect(() => { + // Need to check against display_name which we create from manufacturer and model + const jet = jets.find(j => + `${j.manufacturer} ${j.model}${j.tail_number ? ` (${j.tail_number})` : ''}` === value || + j.display_name === value + ); + + // Special case for "Other (Custom Aircraft)" or any custom model not in our list + if ((!jet && value) || (jet && jet.model === 'Custom')) { + setShowCustomInput(true); + setCustomValue(value); + } else { + setShowCustomInput(false); + } + }, [value, jets]); + + // Filter the jets based on search and selected manufacturer + const filteredJets = jets.filter(jet => { + const displayName = jet.display_name || `${jet.manufacturer} ${jet.model}${jet.tail_number ? ` (${jet.tail_number})` : ''}`; + + const matchesSearch = !search || + displayName.toLowerCase().includes(search.toLowerCase()) || + jet.manufacturer.toLowerCase().includes(search.toLowerCase()) || + jet.model.toLowerCase().includes(search.toLowerCase()) || + (jet.tail_number && jet.tail_number.toLowerCase().includes(search.toLowerCase())); + + const matchesManufacturer = !selectedManufacturer || jet.manufacturer === selectedManufacturer; + + const matchesCapacity = filterByCapacity ? jet.capacity >= filterByCapacity : true; + + return matchesSearch && matchesManufacturer && matchesCapacity; + }); + + // Fix the UI when a jet is selected to display an image and update label + const updateSelectedState = (jet: Jet) => { + setSelectedJet(jet); + // Dispatch the custom event with the full jet data + const jetChangeEvent = new CustomEvent('jetchange', { + detail: { + value: `${jet.manufacturer} ${jet.model}`, + jetId: jet.id, + seatCapacity: jet.capacity, + range: jet.range_nm, + image_url: jet.image_url || `/images/jets/${jet.manufacturer.toLowerCase()}/${jet.model.toLowerCase().replace(/\s+/g, '-')}.jpg` + } + }); + window.dispatchEvent(jetChangeEvent); + }; + + // When handling the jet selection, use this function + const handleSelect = (currentValue: string, jet: Jet) => { + updateSelectedState(jet); + setOpen(false); + if (onChange) { + onChange(currentValue); + } + }; + + // Handle custom input change + const handleCustomInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setCustomValue(newValue); + onChange(newValue); + if (onCustomChange) { + onCustomChange(newValue); + } + }; + + // Capacity filter options + const capacityFilters = [ + { label: 'All', value: null }, + { label: '4+ seats', value: 4 }, + { label: '8+ seats', value: 8 }, + { label: '12+ seats', value: 12 }, + { label: '16+ seats', value: 16 } + ]; + + return ( +
+ + + + + + +
+ + +
+ + {/* Capacity filters */} +
+ {capacityFilters.map((filter) => ( + + ))} +
+ + + + No jets found. + + + {isLoading ? ( + Array(3).fill(0).map((_, index) => ( +
+ +
+ )) + ) : ( + filteredJets.map((jet) => { + const jetName = `${jet.manufacturer} ${jet.model}`; + const isSelected = selectedJet?.id === jet.id; + + return ( + handleSelect(jetName, jet)} + className={cn( + "flex items-center gap-2 px-2 py-3 cursor-pointer transition-colors", + isSelected ? "bg-blue-50 dark:bg-blue-900/20" : "" + )} + > + {jet.image_url ? ( +
+ {jetName} +
+ ) : ( +
+ +
+ )} +
+ {jetName} + + {jet.capacity} seats โ€ข {jet.range_nm} nm range + {jet.year && ` โ€ข ${jet.year}`} + +
+ {isSelected && ( + + )} +
+ ); + }) + )} +
+
+
+
+
+ + {/* Custom input field that appears when "Other" is selected */} + {showCustomInput && ( + + )} +
+ ); +} + +// Public API - This is the component that gets exported and used +export default function JetSelector(props: JetSelectorProps) { + // Use a client-side effect to handle the non-serializable callbacks + const [mounted, setMounted] = useState(false); + + // Ensure component only renders on client side + useEffect(() => { + setMounted(true); + }, []); + + // Don't render until client-side to avoid hydration issues + if (!mounted) { + return
; + } + + // Transform serializable props to actual function handlers + const clientProps: ClientJetSelectorProps = { + ...props, + onChange: (value: string, seatCapacity?: number, jetId?: string) => { + // Handle onChange in the client component + // For state management, you would typically use this with a useState hook in the parent + if (window) { + // Dispatch a custom event that can be listened to by parent components + const event = new CustomEvent('jetchange', { + detail: { + value, + seatCapacity, + jetId + } + }); + window.dispatchEvent(event); + + // If we were given a serializable onChangeValue prop, we'll also need to dispatch an event for it + if (props.onChangeValue) { + const valueEvent = new CustomEvent('jetchange:value', { + detail: { + value + } + }); + window.dispatchEvent(valueEvent); + } + + // If we were given a serializable onChangeSeatCapacity prop, we'll also need to dispatch an event for it + if (props.onChangeSeatCapacity !== undefined) { + const capacityEvent = new CustomEvent('jetchange:capacity', { + detail: { + capacity: seatCapacity + } + }); + window.dispatchEvent(capacityEvent); + } + } + }, + onCustomChange: props.onCustomChangeValue + ? (value: string) => { + if (window) { + const event = new CustomEvent('jetcustomchange', { + detail: { value } + }); + window.dispatchEvent(event); + } + } + : undefined + }; + + return ; +} \ No newline at end of file diff --git a/app/gdyup/components/JetShareAuthFallback.tsx b/app/gdyup/components/JetShareAuthFallback.tsx new file mode 100644 index 00000000..08fafb13 --- /dev/null +++ b/app/gdyup/components/JetShareAuthFallback.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAuth } from '@/components/auth-provider'; + +export default function JetShareAuthFallback() { + const { user, refreshSession } = useAuth(); + + useEffect(() => { + // If no user, try a single session refresh + if (!user && refreshSession) { + console.log('JetShareAuthFallback: No user found, attempting to refresh session...'); + refreshSession().then(success => { + if (success) { + console.log('JetShareAuthFallback: Session refreshed successfully'); + } + }).catch(error => { + console.warn('JetShareAuthFallback: Error refreshing session:', error); + }); + } + }, [user, refreshSession]); + + // This component doesn't render anything + return null; +} \ No newline at end of file diff --git a/app/gdyup/components/JetShareAuthWrapper.tsx b/app/gdyup/components/JetShareAuthWrapper.tsx new file mode 100644 index 00000000..890c23b5 --- /dev/null +++ b/app/gdyup/components/JetShareAuthWrapper.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; +import { toast } from 'sonner'; + +// This wrapper ensures auth state is properly refreshed on the client +export const JetShareAuthWrapper = ({ children }: { children: React.ReactNode }) => { + const [isAuthChecked, setIsAuthChecked] = useState(false); + const supabase = createClientComponentClient(); + + useEffect(() => { + const checkAuthStatus = async () => { + try { + // First check session to ensure cookies are properly set + console.log('JetShareAuthWrapper: Checking auth session...'); + + // Check session on client side + const checkSession = async () => { + try { + // Add timestamp to prevent caching + const timestamp = new Date().getTime(); + const requestId = Math.random().toString(36).substring(2, 10); + + const sessionResponse = await fetch('/api/auth/session', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + }, + // Don't include credentials as it can cause CORS issues + // Supabase handles auth via cookies automatically + }); + + if (!sessionResponse.ok) { + console.warn('JetShareAuthWrapper: Session check failed, but continuing:', + sessionResponse.status, sessionResponse.statusText); + } else { + const sessionData = await sessionResponse.json(); + console.log('JetShareAuthWrapper: Session check result:', + sessionData.authenticated ? 'Authenticated' : 'Not authenticated'); + } + } catch (error) { + console.error('JetShareAuthWrapper: Error checking session:', error); + } + }; + + await checkSession(); + + // Now check auth state directly with Supabase + const { data, error } = await supabase.auth.getUser(); + if (error) { + console.warn('JetShareAuthWrapper: Auth error:', error); + } else if (data?.user) { + console.log('JetShareAuthWrapper: User authenticated:', data.user.id); + } else { + console.log('JetShareAuthWrapper: No user found, not authenticated'); + } + } catch (error) { + console.error('JetShareAuthWrapper: Error checking auth:', error); + } finally { + setIsAuthChecked(true); + } + }; + + checkAuthStatus(); + }, [supabase]); + + // Simply render children - this component just ensures auth is checked + return <>{children}; +}; \ No newline at end of file diff --git a/app/gdyup/components/JetShareDashboard.tsx b/app/gdyup/components/JetShareDashboard.tsx new file mode 100644 index 00000000..72b121f5 --- /dev/null +++ b/app/gdyup/components/JetShareDashboard.tsx @@ -0,0 +1,838 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useRouter } from 'next/navigation'; +import { formatCurrency } from '@/lib/utils'; +import { JetShareOfferWithUser, JetShareTransactionWithDetails } from '@/types/jetshare'; +import { format } from 'date-fns'; +import { CheckCircle, Clock, AlertCircle, Plane, Users, CreditCard, Ticket, Wallet } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from 'sonner'; +import { createClient } from '@/lib/supabase'; +import { useAuth } from '@/components/auth-provider'; +import { v4 as uuidv4 } from 'uuid'; + +// Add props interface +interface JetShareDashboardProps { + initialTab?: 'dashboard' | 'offers' | 'bookings' | 'transactions'; + errorMessage?: string; + successMessage?: string; +} + +// Define type for stats +interface JetShareStats { + totalOffers: number; + totalBookings: number; + totalSpent: number; + totalEarned: number; +} + +export default function JetShareDashboard({ initialTab = 'dashboard', errorMessage, successMessage }: JetShareDashboardProps) { + const router = useRouter(); + const { refreshSession, user } = useAuth(); + const [activeTab, setActiveTab] = useState(initialTab); + const [myOffers, setMyOffers] = useState([]); + const [myBookings, setMyBookings] = useState([]); + const [completedFlights, setCompletedFlights] = useState([]); + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(errorMessage || null); + const [stats, setStats] = useState({ + totalOffers: 0, + totalBookings: 0, + totalSpent: 0, + totalEarned: 0 + }); + + // Display success message if provided + useEffect(() => { + if (successMessage) { + toast.success( + successMessage === 'already-paid' + ? 'This offer has already been paid for.' + : successMessage + ); + } + + if (errorMessage && !error) { + setError( + errorMessage === 'unauthorized' + ? 'You are not authorized to view this offer.' + : errorMessage === 'offer-not-found' + ? 'The requested offer could not be found.' + : errorMessage === 'not-matched-user' + ? 'You are not the matched user for this offer.' + : errorMessage === 'invalid-offer-state' + ? 'The offer is not in a valid state for this action.' + : errorMessage + ); + } + }, [successMessage, errorMessage, error]); + + useEffect(() => { + const fetchData = async () => { + try { + setIsLoading(true); + + // Generate unique request identifiers + const timestamp = Date.now(); + const requestId = Math.random().toString(36).substring(2, 10); + const instanceId = uuidv4(); + + // Get the user's token for authenticated requests + const supabase = createClient(); + let session = null; + let userId = null; + + try { + const { data: sessionData, error: sessionError } = await supabase.auth.getSession(); + + if (sessionError) { + console.error('Session error:', sessionError); + } else if (sessionData?.session) { + session = sessionData.session; + userId = sessionData.session.user?.id; + if (userId) { + console.log('Dashboard: Using authenticated session for user', userId); + } else { + console.log('Dashboard: Session found but user ID is missing'); + } + } else { + console.log('Dashboard: No valid session found'); + } + } catch (sessionError) { + console.error('Error getting session:', sessionError); + } + + // If no session, try to get user ID from localStorage + if (!userId) { + try { + userId = localStorage.getItem('jetstream_user_id'); + if (userId) { + console.log('Dashboard: Using user ID from localStorage:', userId); + } + } catch (storageError) { + console.error('Error accessing localStorage:', storageError); + } + } + + // If still no user ID, use the user from the auth context + if (!userId && user) { + userId = user.id; + console.log('Dashboard: Using user ID from auth context:', userId); + } + + // If we still don't have a user ID at this point, we're in trouble + if (!userId) { + console.error('Unable to determine user ID for dashboard'); + setError('Authentication issue occurred. Please refresh the page or sign in again.'); + setIsLoading(false); + + // Still show empty state rather than throwing + setMyOffers([]); + setMyBookings([]); + setCompletedFlights([]); + setTransactions([]); + setStats({ + totalOffers: 0, + totalBookings: 0, + totalSpent: 0, + totalEarned: 0, + }); + return; + } + + // Prepare headers with auth token and cache control + const headers: Record = { + 'Cache-Control': 'no-cache', + }; + + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + // Log the dashboard request + console.log(`Dashboard fetch for user ${userId} at ${new Date().toISOString()}`); + + try { + // Fetch offers (this may fail, which is ok) + let postedOffers = []; + let acceptedOffers = []; + let completedOffers = []; + + try { + const offersResponse = await fetch(`/api/jetshare/getOffers?viewMode=dashboard&user_id=${userId}&t=${timestamp}&rid=${requestId}&instance_id=${instanceId}`, { + headers, + credentials: 'include', + }); + + if (!offersResponse.ok) { + console.error('Error fetching offers:', offersResponse.status); + console.warn('Unable to fetch offers, will display empty offers'); + } else { + // Process offers normally + const offersData = await offersResponse.json(); + + if (offersData.offers && Array.isArray(offersData.offers)) { + // Separate offers by status + postedOffers = offersData.offers.filter((offer: any) => offer.status === 'open') || []; + acceptedOffers = offersData.offers.filter((offer: any) => offer.status === 'accepted') || []; + completedOffers = offersData.offers.filter((offer: any) => offer.status === 'completed') || []; + console.log(`Found ${offersData.offers.length} offers: ${postedOffers.length} open, ${acceptedOffers.length} accepted, ${completedOffers.length} completed`); + } else { + console.warn('Offers data is not in expected format:', offersData); + } + } + } catch (offersError) { + console.error('Error fetching offers:', offersError); + console.warn('Will display empty offers'); + } + + // Set offer state with whatever we got, even if empty + setMyOffers(postedOffers); + setMyBookings(acceptedOffers); + setCompletedFlights(completedOffers); + + // Continue with stats regardless of offers success + await proceedWithStats(headers, userId, timestamp, requestId, instanceId); + } catch (fetchError) { + console.error('Error in main fetch operation:', fetchError); + setError('Unable to load your dashboard data. Please try again later.'); + + // Set empty data to avoid crashes + setMyOffers([]); + setMyBookings([]); + setCompletedFlights([]); + setStats({ + totalOffers: 0, + totalBookings: 0, + totalSpent: 0, + totalEarned: 0, + }); + + setIsLoading(false); + } + } catch (error) { + console.error('Dashboard error:', error); + setError('An unexpected error occurred. Please refresh the page.'); + setIsLoading(false); + } + }; + + // Setup instance ID if not present + if (typeof window !== 'undefined' && !localStorage.getItem('jetstream_instance_id')) { + // Generate a UUID for instance tracking + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + localStorage.setItem('jetstream_instance_id', uuid); + console.log('Created new instance ID for session tracking:', uuid); + } + + fetchData(); + }, [refreshSession]); + + // Helper function to fetch stats to avoid code duplication + const proceedWithStats = async (headers: Record, userId: string, timestamp: number, requestId: string, instanceId: string) => { + try { + // 1. First fetch transactions + let transactions = []; + try { + const transactionsResponse = await fetch(`/api/jetshare/getTransactions?user_id=${userId}&t=${timestamp}&rid=${requestId}&instance_id=${instanceId}`, { + headers, + credentials: 'include', + }); + + if (!transactionsResponse.ok) { + console.error('Error fetching transactions:', transactionsResponse.status); + console.warn('Will continue with empty transactions data'); + } else { + const transactionsData = await transactionsResponse.json(); + transactions = transactionsData.transactions || []; + } + } catch (txError) { + console.error('Transaction fetch failed:', txError); + console.warn('Will continue with empty transactions data'); + } + + // Set transactions regardless of success/failure + setTransactions(transactions); + + // 2. Fetch user stats (or use default values if this fails) + let stats = { + totalOffers: 0, + totalBookings: 0, + totalSpent: 0, + totalEarned: 0, + }; + + try { + const statsResponse = await fetch(`/api/jetshare/stats?user_id=${userId}&t=${timestamp}&rid=${requestId}&instance_id=${instanceId}`, { + headers, + credentials: 'include', + }); + + if (!statsResponse.ok) { + console.error('Error fetching stats:', statsResponse.status); + console.warn('Will use default stats values'); + } else { + const statsData = await statsResponse.json(); + if (statsData.stats) { + stats = statsData.stats; + } + } + } catch (statsError) { + console.error('Stats fetch failed:', statsError); + console.warn('Will use default stats values'); + } + + // Set stats regardless of success/failure + setStats(stats); + + // Done loading + setIsLoading(false); + } catch (error) { + console.error('Error in stats/transactions fetching:', error); + // Set default values + setTransactions([]); + setStats({ + totalOffers: 0, + totalBookings: 0, + totalSpent: 0, + totalEarned: 0, + }); + setIsLoading(false); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'open': + return Open; + case 'accepted': + return Accepted; + case 'completed': + return Completed; + default: + return {status}; + } + }; + + const getPaymentStatusBadge = (status: string) => { + switch (status) { + case 'pending': + return + + Pending + ; + case 'completed': + return + + Completed + ; + case 'failed': + return + + Failed + ; + default: + return {status}; + } + }; + + const renderOffer = (offer: JetShareOfferWithUser & { isOwnOffer?: boolean }) => { + if (!offer) { + return null; + } + + // Ensure we have valid dates and amounts + const flightDate = offer.flight_date ? new Date(offer.flight_date) : new Date(); + const totalFlightCost = typeof offer.total_flight_cost === 'number' ? offer.total_flight_cost : 0; + const requestedShareAmount = typeof offer.requested_share_amount === 'number' ? offer.requested_share_amount : 0; + + // Safe status with default + const status = offer.status || 'unknown'; + + // Safe locations with defaults + const departureLocation = offer.departure_location || 'Unknown'; + const arrivalLocation = offer.arrival_location || 'Unknown'; + + return ( + + {/* Status indicator bar at the top */} +
+ +
{ + if (!offer.id) return; + + if (status === 'completed') { + router.push(`/jetshare/transaction/${offer.id}`); + } else if (status === 'accepted') { + // If this is a booking we made (we are the matched_user) + if (offer.matched_user_id && offer.matched_user?.id === offer.matched_user_id) { + router.push(`/jetshare/payment/${offer.id}`); + } else { + // If we created this offer and it's accepted but not paid for + router.push(`/jetshare/offer/${offer.id}`); + } + } else if (status === 'open') { + // For open offers, navigate to the offer detail page + router.push(`/jetshare/offer/${offer.id}`); + } + }} + > + +
+ + + {departureLocation} โ†’ {arrivalLocation} + + {getStatusBadge(status)} +
+
+
+ {format(flightDate, 'MMM d, yyyy')} +
+
+ Flight #{offer.id?.toString().substring(0, 6)} +
+
+
+ +
+
+ + + Total Cost + + {formatCurrency(totalFlightCost)} +
+
+ + + Share Amount + + {formatCurrency(requestedShareAmount)} +
+ + {/* Show additional information based on status */} + {status === 'accepted' && ( +
+ {offer.isOwnOffer ? ( + // If this is my offer and someone accepted it +
+ + + Awaiting payment from {offer.matched_user?.first_name || 'Traveler'} + +
+ ) : ( + // If I accepted someone else's offer +
+ + + Payment required + + +
+ )} +
+ )} + + {status === 'completed' && ( +
+
+ + + Payment completed + +
+ + +
+
+
+ )} +
+
+
+ + ); + }; + + const renderTransaction = (transaction: JetShareTransactionWithDetails, currentUserId?: string) => { + if (!transaction) { + return null; + } + + // Safely access properties - updated to match the schema + const isPayer = transaction.payer_user_id === currentUserId; + const isRecipient = transaction.recipient_user_id === currentUserId; + + // Ensure we have valid dates and amounts + const transactionDate = transaction.transaction_date ? new Date(transaction.transaction_date) : new Date(); + const amount = typeof transaction.amount === 'number' ? transaction.amount : 0; + const handlingFee = typeof transaction.handling_fee === 'number' ? transaction.handling_fee : 0; + + return ( + +
transaction.offer_id ? router.push(`/jetshare/transaction/${transaction.offer_id}`) : null}> + +
+ + {isPayer ? ( + + + Payment Sent + + ) : isRecipient ? ( + + + Payment Received + + ) : ( + + + Transaction + + )} + + {getPaymentStatusBadge(transaction.payment_status)} +
+
+ {transaction.offer && ( +
+ + {transaction.offer.departure_location} โ†’ {transaction.offer.arrival_location} +
+ )} +
+
+ {format(transactionDate, 'MMM d, yyyy')} +
+
+ Txn #{transaction.id?.toString().substring(0, 6)} +
+
+
+
+ +
+
+ Transaction Amount: + {formatCurrency(amount)} +
+
+ Handling Fee: + {formatCurrency(handlingFee)} +
+
+ + {isPayer ? 'Total Paid:' : isRecipient ? 'Total Received:' : 'Total:'} + + + {formatCurrency(amount)} + +
+ +
+
+ {isPayer ? ( + <> + To: + + {transaction.recipient_user?.first_name || 'Recipient'} {transaction.recipient_user?.last_name || ''} + + + ) : isRecipient ? ( + <> + From: + + {transaction.payer_user?.first_name || 'Payer'} {transaction.payer_user?.last_name || ''} + + + ) : ( + Transaction ID: {transaction.id?.substring(0, 8) || 'Unknown'}... + )} +
+ {transaction.payment_method === 'crypto' ? 'Crypto' : 'Credit Card'} +
+
+
+
+
+ ); + }; + + const renderSkeleton = () => ( + <> + {[1, 2].map((i) => ( + + + + + + +
+ + + +
+
+
+ ))} + + ); + + // Add a minimum timeout to prevent endless loading + useEffect(() => { + const timer = setTimeout(() => { + if (isLoading) { + console.log('Dashboard: Forcing loading to complete after timeout'); + setIsLoading(false); + } + }, 10000); // 10 seconds max loading time + + return () => clearTimeout(timer); + }, [isLoading]); + + return ( +
+ {error && ( +
+ + {error} +
+ )} + + + + Dashboard + My Offers + My Bookings + Transactions + + + +
+ + + Total Offers Created + + + {isLoading ? ( + + ) : ( +
{stats.totalOffers}
+ )} +
+
+ + + Total Bookings + + + {isLoading ? ( + + ) : ( +
{stats.totalBookings}
+ )} +
+
+ + + Total Spent + + + {isLoading ? ( + + ) : ( +
{formatCurrency(stats.totalSpent)}
+ )} +
+
+ + + Total Earned + + + {isLoading ? ( + + ) : ( +
{formatCurrency(stats.totalEarned)}
+ )} +
+
+
+ +
+ + + Recent Activity + + + {isLoading ? ( + renderSkeleton() + ) : transactions.length > 0 ? ( +
+ {transactions.slice(0, 3).map(transaction => renderTransaction(transaction, user?.id))} + {transactions.length > 3 && ( + + )} +
+ ) : ( +
+ No recent activity +
+ )} +
+
+
+
+ + + + +
+ My Posted Offers + +
+
+ + {isLoading ? ( + renderSkeleton() + ) : myOffers.length > 0 ? ( +
+ {myOffers.map(offer => renderOffer({...offer, isOwnOffer: true}))} +
+ ) : ( +
+ You haven't posted any offers yet +
+ +
+
+ )} +
+
+
+ + + + + My Bookings + + + {isLoading ? ( + renderSkeleton() + ) : myBookings.length > 0 ? ( +
+ {myBookings.map(offer => renderOffer(offer))} +
+ ) : ( +
+ You haven't booked any flights yet +
+ +
+
+ )} +
+
+ + {completedFlights.length > 0 && ( + + + Completed Flights + + +
+ {completedFlights.map(offer => renderOffer(offer))} +
+
+
+ )} +
+ + + + + Transaction History + + + {isLoading ? ( + renderSkeleton() + ) : transactions.length > 0 ? ( +
+ {transactions.map(transaction => renderTransaction(transaction, user?.id))} +
+ ) : ( +
+ No transactions yet +
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/gdyup/components/JetShareHeader.tsx b/app/gdyup/components/JetShareHeader.tsx new file mode 100644 index 00000000..6efe0d12 --- /dev/null +++ b/app/gdyup/components/JetShareHeader.tsx @@ -0,0 +1,323 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { + Home, + Menu, + X, + Search, + PlaneTakeoff, + BarChart4, + LogOut, + ChevronLeft, + LogIn, + User +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/components/auth-provider'; +import { cn } from '@/lib/utils'; +import { createClient } from '@/lib/supabase'; + +export default function JetShareHeader() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const pathname = usePathname(); + const router = useRouter(); + const { user, loading, signOut } = useAuth(); + const [isClient, setIsClient] = useState(false); + const [hasLocalAuth, setHasLocalAuth] = useState(false); + + useEffect(() => { + setIsClient(true); + + // Check for auth in localStorage as a fallback + const checkLocalAuth = () => { + try { + const tokenData = localStorage.getItem('sb-vjhrmizwqhmafkxbmfwa-auth-token'); + const userId = localStorage.getItem('jetstream_user_id'); + + // If we have either token data or user_id stored, consider this as potential auth + setHasLocalAuth(!!(tokenData || userId)); + + // If we have token but no user in Auth provider, try to restore session + if ((tokenData || userId) && !user && !loading) { + console.log('Header: Found auth data in localStorage but no user in context - refreshing auth state'); + + // This will trigger the auth provider to try restoring the session + const refreshAuth = async () => { + try { + const supabase = createClient(); + await supabase.auth.refreshSession(); + } catch (e) { + console.warn('Header: Error refreshing session:', e); + } + }; + + refreshAuth(); + } + } catch (e) { + console.warn('Header: Error checking localStorage:', e); + setHasLocalAuth(false); + } + }; + + checkLocalAuth(); + + // Re-check authentication every 5 seconds in case it changes + // This helps when redirecting from auth page back to JetShare + const intervalId = setInterval(checkLocalAuth, 5000); + + return () => clearInterval(intervalId); + }, [user, loading]); + + // Determine if user is authenticated - either through Auth provider or localStorage + const isAuthenticated = !!user || (!loading && isClient && hasLocalAuth); + + const isActive = (path: string) => { + return pathname === path; + }; + + // Define menu items based on authentication status + const getMenuItems = () => { + // Items available to all users + const publicItems = [ + { + name: 'Home', + path: '/jetshare', + icon: + }, + { + name: 'Listings', + path: '/jetshare/listings', + icon: + } + ]; + + // Items that require authentication + const authItems = isAuthenticated ? [ + { + name: 'Offer a Share', + path: '/jetshare/offer', + icon: + }, + { + name: 'Dashboard', + path: '/jetshare/dashboard', + icon: + } + ] : []; + + // Debug item (development only) + const devItems = process.env.NODE_ENV === 'development' ? [ + { + name: 'Debug', + path: '/jetshare/debug', + icon: DEV + } + ] : []; + + return [...publicItems, ...authItems, ...devItems]; + }; + + const menuItems = getMenuItems(); + + const handleSignOut = async () => { + try { + await signOut(); + router.push('/'); + } catch (error) { + console.error('Sign out error:', error); + } + }; + + const handleSignIn = () => { + // Redirect back to JetShare after login with current path + const currentPath = pathname || '/jetshare'; + const timestamp = Date.now(); // Add timestamp to avoid caching issues + router.push(`/auth/login?returnUrl=${encodeURIComponent(currentPath)}&t=${timestamp}`); + }; + + return ( +
+
+
+ {/* Logo */} +
+ + JetShare + +
+ + {/* Desktop Navigation */} + + + {/* Mobile Menu Button */} + +
+ + {/* Mobile Navigation */} + {mobileMenuOpen && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/app/gdyup/components/JetShareListingsContent.tsx b/app/gdyup/components/JetShareListingsContent.tsx new file mode 100644 index 00000000..9ad3f9f6 --- /dev/null +++ b/app/gdyup/components/JetShareListingsContent.tsx @@ -0,0 +1,1529 @@ +'use client'; + +import { useRef, useState, useEffect, Fragment, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { createClient } from '@/lib/supabase'; +import { + Plane, + Calendar, + DollarSign, + Search, + Loader2, + Info, + Filter, + X, + Badge, + ArrowRight, + BadgeCheck, + CheckCircle, + CreditCard, + Bitcoin, + MoveUp, + MoveDown, + MapPin, + Users, + MoreVertical, + LoaderCircle, + RefreshCw, + Clock +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { JetShareOfferWithUser, JetShareOfferStatus } from '@/types/jetshare'; +import { Badge as UIBadge } from '@/components/ui/badge'; +import { format, parseISO } from 'date-fns'; +import { toast } from 'sonner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { User } from '@supabase/supabase-js'; +import { Calendar as CalendarComponent } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { formatCurrency, formatTime } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Slider } from '@/components/ui/slider'; +import { Separator } from '@/components/ui/separator'; +import Link from 'next/link'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useAuth } from '@/components/auth-provider'; + +// Update the JetShareOfferWithUser type to include the isOwnOffer flag +interface EnhancedJetShareOfferWithUser extends JetShareOfferWithUser { + isOwnOffer?: boolean; + image_url?: string; + jet_id?: string; + jet?: { + id?: string; + manufacturer?: string; + model?: string; + image_url?: string; + images?: string[]; + category?: string; + capacity?: number; + range_nm?: number; + cruise_speed_kts?: number; + tail_number?: string; + description?: string; + [key: string]: any; + }; +} + +// Type for user profile with verification status +interface UserWithVerification { + id: string; + first_name?: string; + last_name?: string; + email?: string; + avatar_url?: string; + verification_status?: string; +} + +interface JetShareListingsContentProps { + // User is already provided by useAuth(), so no need to pass it in +} + +// Placeholder component for empty state +const EmptyState = () => ( +
+
+ +
+

No Flight Shares Available

+

+ There are no flight shares available that match your criteria. Try adjusting your filters or check back later. +

+ +
+); + +// Skeleton loader for cards +const SkeletonCard = () => ( + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+); + +export default function JetShareListingsContent() { + const router = useRouter(); + const { user, refreshSession } = useAuth(); + const [isLoading, setIsLoading] = useState(true); + const [isAccepting, setIsAccepting] = useState(false); + const [offers, setOffers] = useState([]); + const [filteredOffers, setFilteredOffers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOffer, setSelectedOffer] = useState(null); + const [sortOption, setSortOption] = useState('date-asc'); + const [error, setError] = useState(null); + + // Filter states + const [departureFilter, setDepartureFilter] = useState(''); + const [arrivalFilter, setArrivalFilter] = useState(''); + const [minPriceFilter, setMinPriceFilter] = useState(''); + const [maxPriceFilter, setMaxPriceFilter] = useState(''); + const [locationFilter, setLocationFilter] = useState(''); + + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showDetailDialog, setShowDetailDialog] = useState(false); + + const supabase = createClient(); + + // Add a new effect to check for offer status changes when the component gains focus + useEffect(() => { + // Function to check if page visibility changes (user returns to the tab) + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + console.log('Page became visible, refreshing offers'); + fetchOffers(); + } + }; + + // Function to handle when user navigates back to this page + const handleFocus = () => { + console.log('Window regained focus, refreshing offers'); + fetchOffers(); + }; + + // Add event listeners + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); + + // Clean up event listeners + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); + }; + }, []); + + // Fetch offers from API + const fetchOffers = async ( + status = 'open', + viewMode: 'marketplace' | 'dashboard' = 'marketplace', + userId?: string, + retry = 0 + ) => { + console.log(`Fetching ${status} offers for ${viewMode} view (retry: ${retry})`); + if (retry > 3) { + console.error('Max retries reached, giving up'); + setError('Unable to load offers after multiple attempts. Please refresh the page.'); + setIsLoading(false); + return; + } + + setIsLoading(true); + + try { + // Get auth token for API calls + let authToken = null; + let authUserId = userId; + + // Try to get token from supabase auth + try { + const supabase = createClient(); + const { data: sessionData } = await supabase.auth.getSession(); + + if (sessionData?.session?.access_token) { + authToken = sessionData.session.access_token; + authUserId = authUserId || sessionData.session?.user?.id; + console.log('Using current session auth token for listings'); + } + } catch (sessionError) { + console.warn('Error getting session:', sessionError); + } + + // If no token from session, try localStorage + if (!authToken) { + try { + const tokenData = localStorage.getItem('sb-vjhrmizwqhmafkxbmfwa-auth-token'); + if (tokenData) { + try { + const parsedToken = JSON.parse(tokenData); + if (parsedToken && parsedToken.access_token) { + authToken = parsedToken.access_token; + console.log('Using token from localStorage'); + + // Also try to get user ID from localStorage if not provided + if (!authUserId) { + authUserId = localStorage.getItem('jetstream_user_id') || undefined; + if (authUserId) { + console.log('Using user_id from localStorage'); + } + } + } + } catch (parseError) { + console.warn('Error parsing localStorage token:', parseError); + } + } + } catch (storageError) { + console.warn('Error accessing localStorage:', storageError); + } + } + + // Construct the API URL with query parameters + let url = `/api/jetshare/getOffers?status=${status}&viewMode=${viewMode}`; + + // Add parameter to request jet details + url += '&include_aircraft_details=true'; + + // Always append a timestamp to bust cache + url += `&t=${Date.now()}`; + + // Basic request headers + const headers: Record = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + }; + + // Add auth token if available + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // Add user_id as query param for private browsing mode + if (authUserId && url.indexOf('user_id=') === -1) { + url += `&user_id=${encodeURIComponent(authUserId)}`; + } + + // Log request details for debugging + console.log(`JetShare request URL: ${url.substring(0, 100)}${url.length > 100 ? '...' : ''}`); + console.log('JetShare request headers:', Object.keys(headers).join(', ')); + + // Add timeout with AbortController + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout (increased from 10) + + const response = await fetch(url, { + method: 'GET', + headers, + credentials: 'include', // Important for cookie-based auth + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + // Handle auth errors specifically + if (response.status === 401 || response.status === 403) { + console.error('Authentication error fetching offers:', response.status); + + // If this is already a retry, fail gracefully + if (retry >= 2) { + console.error('Max retries reached for auth error, giving up'); + setError('Authentication failed. Please try refreshing the page or logging in again.'); + setIsLoading(false); + return; + } + + // Try to refresh auth and retry + try { + console.log('Auth error, attempting to refresh session and retry...'); + const supabase = createClient(); + const { data } = await supabase.auth.refreshSession(); + + if (data.session) { + console.log('Session refreshed, retrying fetch...'); + // Wait a moment for auth to propagate + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Retry with incremented retry count + return fetchOffers(status, viewMode, userId, retry + 1); + } + } catch (refreshError) { + console.error('Error refreshing session:', refreshError); + } + } else if (response.status === 500) { + // For server errors, wait and retry with backoff + console.error('Server error (500) fetching offers. Retrying with backoff...'); + + // Check response for specific database relationship error + try { + const errorData = await response.json(); + + // Check for the specific relationship error + if (errorData?.details?.includes('relationship between') && + errorData?.details?.includes('jetshare_profiles')) { + console.log('Detected database relationship error with profiles. Continuing with empty results...'); + + // Instead of retrying, just continue with empty results since we know it will fail again + setOffers([]); + setError('Unable to load user profiles. Basic listing information is still available.'); + setIsLoading(false); + return; + } + } catch (parseError) { + // If we can't parse the response, just continue with standard retry logic + console.warn('Could not parse error response:', parseError); + } + + const backoffTime = Math.min(1000 * (retry + 1), 5000); // Exponential backoff with max of 5 seconds + + setTimeout(() => { + fetchOffers(status, viewMode, userId, retry + 1); + }, backoffTime); + + return; + } + + // General error handling + let errorMessage = 'Failed to fetch offers'; + try { + const errorData = await response.json(); + errorMessage = errorData.message || errorData.error || errorMessage; + console.error('Error details:', errorData); + } catch (e) { + // If we can't parse JSON, use status text + errorMessage = response.statusText || errorMessage; + } + + setError(errorMessage); + + // For all errors, show empty state after a few seconds rather than spinning forever + setTimeout(() => { + setIsLoading(false); + setOffers([]); + }, 2000); + + return; + } + + // Process successful response + const data = await response.json(); + + if (!data.offers || !Array.isArray(data.offers)) { + console.warn('Unexpected response format - missing offers array:', data); + setOffers([]); + } else { + // Enhance offers with default user info if missing + const enhancedOffers = data.offers.map((offer: JetShareOfferWithUser) => { + // Mark offers created by current user + const isOwnOffer = authUserId && offer.user_id === authUserId; + + // Extract image URL from jet relation if available + let imageUrl = null; + if (offer.jet && offer.jet.image_url) { + imageUrl = offer.jet.image_url; + console.log(`Extracted image URL from jet relation: ${imageUrl}`); + } + + // Check if user info is missing and provide defaults + if (!offer.user) { + return { + ...offer, + isOwnOffer, + image_url: imageUrl, + user: { + id: offer.user_id, + first_name: "Jet", + last_name: "Owner" + } + }; + } + return { + ...offer, + isOwnOffer, + image_url: imageUrl + }; + }); + + setOffers(enhancedOffers || []); + console.log(`Fetched ${enhancedOffers?.length || 0} ${status} offers`); + } + + setError(null); + } catch (error) { + console.error('Error fetching offers:', error); + + // Handle aborted requests separately + if (error instanceof DOMException && error.name === 'AbortError') { + setError('Request timed out. Please try again.'); + } else { + setError('Failed to fetch offers. Please try again later.'); + } + + // For network errors, retry once after a delay + if (error instanceof TypeError && error.message.includes('fetch') && retry < 2) { + console.log('Network error, retrying after delay...'); + setTimeout(() => { + fetchOffers(status, viewMode, userId, retry + 1); + }, 2000); + return; + } + + // Set empty offers array on error to avoid showing stale data + setOffers([]); + } finally { + setIsLoading(false); + } + }; + + // Fetch offers on mount and at intervals + useEffect(() => { + // Execute fetch on component mount + fetchOffers(); + + // Set up a refresh interval to occasionally reload the offers + const interval = setInterval(() => { + fetchOffers(); + }, 60000); // Refresh every minute + + return () => clearInterval(interval); + }, [user, router]); + + // Check for resumed offer acceptance after login + useEffect(() => { + // Only try to resume if user is authenticated + if (user) { + try { + const resumeOfferId = sessionStorage.getItem('jetshare_resume_offer_acceptance'); + if (resumeOfferId) { + console.log('Resuming offer acceptance after login:', resumeOfferId); + + // Clear from session storage to prevent repeated attempts + sessionStorage.removeItem('jetshare_resume_offer_acceptance'); + + // Find the offer in our loaded offers + const offerToResume = offers.find(offer => offer.id === resumeOfferId); + if (offerToResume) { + // Trigger the confirmation dialog + setSelectedOffer(offerToResume); + setShowConfirmDialog(true); + } else { + // If we can't find the offer, refresh to see if it's still available + console.log('Offer not found in current list, refreshing...'); + fetchOffers(); + } + } + } catch (e) { + console.warn('Could not access sessionStorage:', e); + } + } + }, [user, offers]); + + // Filter and sort offers + useEffect(() => { + console.log('Filter/sort effect running, offers length:', offers.length); + let result = [...offers]; + + // Apply search term filter + if (searchTerm) { + const term = searchTerm.toLowerCase(); + result = result.filter(offer => + offer.departure_location.toLowerCase().includes(term) || + offer.arrival_location.toLowerCase().includes(term) + ); + } + + // Apply departure filter + if (departureFilter) { + result = result.filter(offer => + offer.departure_location.toLowerCase().includes(departureFilter.toLowerCase()) + ); + } + + // Apply arrival filter + if (arrivalFilter) { + result = result.filter(offer => + offer.arrival_location.toLowerCase().includes(arrivalFilter.toLowerCase()) + ); + } + + // Apply price filters + if (minPriceFilter) { + const minPrice = parseFloat(minPriceFilter); + if (!isNaN(minPrice)) { + result = result.filter(offer => offer.requested_share_amount >= minPrice); + } + } + + if (maxPriceFilter) { + const maxPrice = parseFloat(maxPriceFilter); + if (!isNaN(maxPrice)) { + result = result.filter(offer => offer.requested_share_amount <= maxPrice); + } + } + + // Apply sorting + switch (sortOption) { + case 'date-asc': + result.sort((a, b) => new Date(a.flight_date).getTime() - new Date(b.flight_date).getTime()); + break; + case 'date-desc': + result.sort((a, b) => new Date(b.flight_date).getTime() - new Date(a.flight_date).getTime()); + break; + case 'price-asc': + result.sort((a, b) => a.requested_share_amount - b.requested_share_amount); + break; + case 'price-desc': + result.sort((a, b) => b.requested_share_amount - a.requested_share_amount); + break; + } + + console.log('Setting filtered offers:', result.length); + setFilteredOffers(result); + }, [offers, searchTerm, sortOption, departureFilter, arrivalFilter, minPriceFilter, maxPriceFilter]); + + // Ensure auth session is fresh before making important API calls + const ensureAuthSession = async (): Promise => { + console.log('Ensuring fresh auth session before API call...'); + + // First try the standard refresh via auth context + try { + const refreshed = await refreshSession(); + if (refreshed) { + console.log('Session refreshed successfully via auth context'); + return true; + } + } catch (refreshError) { + console.warn('Error in standard session refresh:', refreshError); + } + + // If that fails, try direct client refresh + try { + const supabase = createClient(); + const { data, error } = await supabase.auth.refreshSession(); + + if (!error && data.session) { + console.log('Session refreshed successfully via direct client call'); + return true; + } else if (error) { + console.warn('Failed to refresh session via direct client call:', error); + } + } catch (directRefreshError) { + console.warn('Error in direct session refresh:', directRefreshError); + } + + // Try multiple sources for auth token - for improved reliability + let authToken = null; + let authTokenSource = ''; + + // First try to get token from supabase auth + try { + const { data: sessionData } = await supabase.auth.getSession(); + if (sessionData?.session?.access_token) { + authToken = sessionData.session.access_token; + authTokenSource = 'current session'; + } + } catch (sessionError) { + console.warn('Error getting session:', sessionError); + } + + // Then try localStorage JWT token if available + if (!authToken) { + try { + const storedToken = localStorage.getItem('sb-vjhrmizwqhmafkxbmfwa-auth-token'); + if (storedToken) { + const tokenData = JSON.parse(storedToken); + if (tokenData?.access_token) { + authToken = tokenData.access_token; + authTokenSource = 'localStorage'; + } + } + } catch (e) { + console.warn('Error accessing localStorage for auth token:', e); + } + } + + // If we still don't have a token, try refresh + if (!authToken) { + try { + console.log('No auth token found, attempting to refresh session...'); + const { data: refreshData } = await supabase.auth.refreshSession(); + if (refreshData?.session?.access_token) { + authToken = refreshData.session.access_token; + authTokenSource = 'refreshed session'; + } + } catch (refreshError) { + console.warn('Error refreshing session:', refreshError); + } + } + + return false; + }; + + const handleOfferAccept = async (offer: EnhancedJetShareOfferWithUser) => { + setSelectedOffer(offer); + + // Instead of showing a confirmation dialog, go straight to details + setShowDetailDialog(true); + setShowConfirmDialog(false); + }; + + const confirmOfferAccept = async () => { + // Early return if selectedOffer is null + if (!selectedOffer) { + console.error('Cannot accept offer: No offer selected'); + toast.error('Something went wrong. Please try again.'); + return; + } + + try { + // Show loading state immediately for better UX + setIsAccepting(true); + setError(null); + + // Get user ID from all possible sources for robustness + const currentUserId: string | undefined = user?.id; + let hasValidSession = !!user; + let authToken = null; + + // Skip extensive auth recovery attempts if we already have a user ID + if (!currentUserId) { + // Try localStorage as a quick alternative + try { + const storedUserId = localStorage.getItem('jetstream_user_id'); + if (storedUserId) { + console.log('Using cached user ID from localStorage:', storedUserId); + // Use this userId instead of modifying currentUserId (which is const) + // We'll use this in the payload directly + const payload = { + offer_id: selectedOffer.id, + payment_method: 'fiat', // Default to fiat payments + user_id: storedUserId // Type-safe user ID from localStorage + }; + + console.log('Accepting offer with payload:', { offer_id: payload.offer_id, user_id: payload.user_id }); + + // Make the API call to accept the offer - simplified headers + const timestamp = Date.now(); + const requestId = Math.random().toString(36).substring(2, 15); + + const response = await fetch(`/api/jetshare/acceptOffer?t=${timestamp}&rid=${requestId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store' + }, + body: JSON.stringify(payload), + credentials: 'include', // Important for cookie-based auth + }); + + // Parse the API response + const apiData = await response.json(); + + // Continue with response handling as before + // Handle API response + if (!response.ok) { + console.warn('Error from accept offer API:', apiData); + + // Special case for authentication errors + if (response.status === 401) { + console.log('Authentication needed for booking. Storing offer info and redirecting to payment page directly.'); + + // Store the offer ID in localStorage for later recovery + try { + localStorage.setItem('current_payment_offer_id', selectedOffer.id); + } catch (e) { + console.warn('Error storing offer ID in localStorage:', e); + } + + // Go directly to the payment page which handles authentication more gracefully + window.location.href = `/jetshare/payment/${selectedOffer.id}?t=${Date.now()}&from=listing_direct`; + return; + } + + throw new Error(apiData.message || 'Failed to accept offer'); + } + + // Handle successful response + console.log('Offer acceptance successful:', apiData); + + // Use the redirect URL from the API if available + let redirectUrl = apiData.data?.redirect_url || `/jetshare/payment/${selectedOffer.id}?from=accept`; + + // Add timestamp to prevent caching issues + if (!redirectUrl.includes('?')) { + redirectUrl += `?t=${Date.now()}`; + } else if (!redirectUrl.includes('t=')) { + redirectUrl += `&t=${Date.now()}`; + } + + // Store essential data for recovery + try { + localStorage.setItem('current_payment_offer_id', selectedOffer.id); + } catch (e) { + console.warn('Error storing offer ID in localStorage:', e); + } + + toast.success('Proceeding to payment...'); + + // Use window.location for a hard redirect + window.location.href = redirectUrl; + return; + } + } catch (e) { + console.warn('Error reading localStorage user ID:', e); + } + + // If still no user ID from localStorage, go directly to payment page + // which has better auth handling + console.log('No user ID found, redirecting to payment page directly'); + try { + localStorage.setItem('current_payment_offer_id', selectedOffer.id); + } catch (e) { + console.warn('Error storing offer ID in localStorage:', e); + } + + // Go to payment page which will handle auth correctly + window.location.href = `/jetshare/payment/${selectedOffer.id}?t=${Date.now()}&from=listing_direct_noauth`; + return; + } + + // If we have a currentUserId, continue with original flow + const payload = { + offer_id: selectedOffer.id, + payment_method: 'fiat', // Default to fiat payments + user_id: currentUserId + }; + + console.log('Accepting offer with payload:', { offer_id: payload.offer_id, user_id: payload.user_id }); + + // Make the API call to accept the offer - simplified headers + const timestamp = Date.now(); + const requestId = Math.random().toString(36).substring(2, 15); + + const response = await fetch(`/api/jetshare/acceptOffer?t=${timestamp}&rid=${requestId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store' + }, + body: JSON.stringify(payload), + credentials: 'include', // Important for cookie-based auth + }); + + // Parse the API response + const apiData = await response.json(); + + // Handle API response + if (!response.ok) { + console.warn('Error from accept offer API:', apiData); + + // Special case for authentication errors + if (response.status === 401) { + console.log('Authentication needed for booking. Storing offer info and redirecting to payment page directly.'); + + // Store the offer ID in localStorage for later recovery + try { + localStorage.setItem('current_payment_offer_id', selectedOffer.id); + } catch (e) { + console.warn('Error storing offer ID in localStorage:', e); + } + + // Go directly to the payment page which handles authentication more gracefully + window.location.href = `/jetshare/payment/${selectedOffer.id}?t=${Date.now()}&from=listing_direct`; + return; + } + + throw new Error(apiData.message || 'Failed to accept offer'); + } + + // Handle successful response + console.log('Offer acceptance successful:', apiData); + + // Use the redirect URL from the API if available + let redirectUrl = apiData.data?.redirect_url || `/jetshare/payment/${selectedOffer.id}?from=accept`; + + // Add timestamp to prevent caching issues + if (!redirectUrl.includes('?')) { + redirectUrl += `?t=${Date.now()}`; + } else if (!redirectUrl.includes('t=')) { + redirectUrl += `&t=${Date.now()}`; + } + + // Store essential data for recovery + try { + localStorage.setItem('current_payment_offer_id', selectedOffer.id); + } catch (e) { + console.warn('Error storing offer ID in localStorage:', e); + } + + toast.success('Proceeding to payment...'); + + // Use window.location for a hard redirect + window.location.href = redirectUrl; + } catch (error) { + console.error('Error accepting offer:', error); + setError(error instanceof Error ? error.message : 'An unknown error occurred'); + toast.error('Failed to accept offer. Please try again.'); + } finally { + setIsAccepting(false); + } + }; + + // Clear all filters + const clearFilters = () => { + setDepartureFilter(''); + setArrivalFilter(''); + setMinPriceFilter(''); + setMaxPriceFilter(''); + setSearchTerm(''); + }; + + // Function to get a valid jet image URL with fallbacks + const getJetImageUrl = (offer: EnhancedJetShareOfferWithUser): string => { + // Log debug information + console.log('Offer debug for image URL:', { + id: offer.id, + aircraft_model: offer.aircraft_model || '', + jet_id: offer.jet_id, + has_jet: !!offer.jet, + jet_details: offer.jet ? { + id: offer.jet.id, + model: offer.jet.model, + manufacturer: offer.jet.manufacturer, + image_url: offer.jet.image_url, + has_images_array: !!offer.jet.images && offer.jet.images.length > 0 + } : null + }); + + // Use proper cascading fallbacks in order of preference: + + // 1. First, check if the offer has a direct image_url property + if (offer.image_url) { + console.log(`Using direct image_url from offer: ${offer.image_url}`); + return offer.image_url; + } + + // 2. Next, try to get the image from the jet relation + if (offer.jet) { + // 2a. Check for direct image_url on the jet + if (offer.jet.image_url) { + console.log(`Using image_url from jet object: ${offer.jet.image_url}`); + return offer.jet.image_url; + } + + // 2b. Check for images array on the jet + if (offer.jet.images && offer.jet.images.length > 0) { + console.log(`Using first image from jet.images array: ${offer.jet.images[0]}`); + return offer.jet.images[0]; + } + + // 2c. Construct path from manufacturer and model if available + if (offer.jet.manufacturer && offer.jet.model) { + const manufacturer = offer.jet.manufacturer.toLowerCase().replace(/[^a-z0-9]/g, ''); + const model = offer.jet.model.toLowerCase().replace(/[^a-z0-9]/g, ''); + const path = `/images/jets/${manufacturer}/${model}.jpg`; + console.log(`Constructed path from jet manufacturer/model: ${path}`); + return path; + } + } + + // 3. Final fallback - use the placeholder image + console.log('No suitable image found, using placeholder'); + return '/images/placeholder-jet.jpg'; + }; + + // Render flight share cards + const renderOfferCard = (offer: EnhancedJetShareOfferWithUser) => ( + !offer.isOwnOffer && handleOfferAccept(offer)} + > + {/* Add background image based on aircraft model */} +