diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b503881 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# TravelorAI — Agent Rules (Codex, Claude, etc.) + +**READ THIS FIRST. These rules are mandatory for all AI agents working in this repo.** + +## 1. Git: which code is current + +- The source of truth is the **`integration/full-merge`** branch (mirrored to local `main`). +- **Before ANY work:** `git fetch origin && git log --oneline -3 origin/integration/full-merge` — make sure your working tree contains those commits. If your checkout is behind, **STOP and update first**. Working on a stale base has already destroyed a day of work once. +- Never commit directly to `main` (it is protected on GitHub; changes go through PR). +- Do not leave work uncommitted. Commit to a feature branch and push. + +## 2. Architecture facts (do not regress these) + +- **Website agency portal is multi-page (portal v2):** `app/agency/{page,leads,tours,tours/new,tours/[id],profile}` + `components/agency/{AgencyShell,AuthScreen,OnboardingScreen,TourEditor,LeadsBoard,...}` + `lib/agency/{api,session,types}`. + - `components/agency/AgencyPortal.tsx` (old 1500-line monolith) is **DELETED**. Never recreate or edit it. +- **Admin panel is multi-page:** `app/admin/{page,moderation,leads,agencies,places,stories,hero}` with `components/admin/AdminShell`. `LandingContentAdmin.tsx` is orphaned — do not extend it. +- Backend `server.js` uses `dotenv {override:true}`; runtime secrets live in the container's `/app/.env` (Google client allowlist, company SMTP). Do not remove. +- Agency Google login: backend route `POST /agency/auth/google` + `googleAuthSchema` + `AgencyAccount.googleId` — required for the website Google button. + +## 3. Production deploy (178.18.245.174) — STRICT + +- **Containers run under PODMAN**: `travelorai_website` (port 3100), `voyageai_backend` (4000). Postgres+redis run under snap-docker (moby) — do not touch. +- **NEVER create new containers, never `docker run`/`compose up` new instances.** The `docker` CLI on the server is symlinked to podman. Creating a second container on the same port causes a restart-policy port war that silently swallows deploys (this happened on 2026-06-12 and wiped live fixes). +- Correct website deploy: + 1. `podman cp travelorai_website:/tmp/` then `podman exec travelorai_website sh -c "cd /app && tar xzf /tmp/src.tar.gz"` + 2. Build inside: `podman exec -e NODE_ENV=production -e NEXT_PUBLIC_SITE_URL=https://travelorai.com -e NEXT_PUBLIC_API_URL=https://travelorai.com/api/v1 -e NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID=401741517790-11c61fghvchvld521kdi0fu4ee1mo2af.apps.googleusercontent.com travelorai_website sh -c "cd /app && node node_modules/next/dist/bin/next build"` + 3. `podman restart travelorai_website` + 4. Also sync the same files to `/opt/voyageai/website/` (host copy for future image builds). +- Correct backend deploy: same pattern with `voyageai_backend`; run `npx prisma generate && npx prisma migrate deploy` inside the container before restart. Keep `/app/.env` intact. +- After deploy ALWAYS verify: `curl -s localhost:4000/api/v1/health`, key pages return 200, and `podman ps` + `ctr -a /run/snap.docker/containerd/containerd.sock -n moby tasks list` show no duplicate port holders. +- `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` must be `401741517790-11c61f...` (NOT `65326209075-...` — that project is inaccessible). + +## 4. Mobile + +- Expo bare workflow. JS-only changes ship via OTA: `npx eas update --channel production --platform android -m "msg"` (runtimeVersion must stay `1.0.7` unless a new binary is released). +- Release AAB: `eas build -p android --profile production` (EAS keystore == Play upload key). versionCode is local-source in `android/app/build.gradle` — bump it for every Play upload. +- Never run local Gradle release builds — the upload keystore is EAS-managed. + +## 5. Language & product + +- UI text in Uzbek only (no i18n yet — explicit product decision). Free lead model: no payments, no commissions; booking must work for guests (name+phone, no forced auth). diff --git a/backend/app.js b/backend/app.js index 2e34b43..435ba2b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -291,7 +291,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { fallthrough: false, maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0, })); -app.use(express.json({ limit: '12mb' })); +app.use(express.json({ limit: '16mb' })); app.use(loggerMiddleware); app.get(['/account-deletion', '/delete-account'], (req, res) => { diff --git a/backend/package.json b/backend/package.json index b99195f..ba11666 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "test": "jest --coverage --passWithNoTests" }, "dependencies": { + "@anthropic-ai/sdk": "^0.104.1", "@prisma/client": "^5.7.0", "axios": "^1.6.2", "bcryptjs": "^2.4.3", diff --git a/backend/prisma/migrations/20260601120000_agency_telegram/migration.sql b/backend/prisma/migrations/20260601120000_agency_telegram/migration.sql new file mode 100644 index 0000000..e237120 --- /dev/null +++ b/backend/prisma/migrations/20260601120000_agency_telegram/migration.sql @@ -0,0 +1,5 @@ +-- Add telegram contact handle for tour agencies (free user↔agency connection) +ALTER TABLE "TourAgency" ADD COLUMN "telegram" TEXT; + +-- Email is now optional for tour booking leads (phone OR email is enough) +ALTER TABLE "TourBooking" ALTER COLUMN "customerEmail" DROP NOT NULL; diff --git a/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql b/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql new file mode 100644 index 0000000..a1fcc8d --- /dev/null +++ b/backend/prisma/migrations/20260607170000_agency_email_images_booking_deadline/migration.sql @@ -0,0 +1,25 @@ +ALTER TYPE "AuthCodeType" ADD VALUE IF NOT EXISTS 'EMAIL_CHANGE'; + +ALTER TABLE "AgencyAccount" + ADD COLUMN IF NOT EXISTS "pendingEmail" TEXT, + ADD COLUMN IF NOT EXISTS "emailChangeResendCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "emailChangeRequestedAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX IF NOT EXISTS "AgencyAccount_pendingEmail_key" + ON "AgencyAccount"("pendingEmail"); + +ALTER TABLE "AgencyApplication" + ADD COLUMN IF NOT EXISTS "imageUrl" TEXT; + +ALTER TABLE "Tour" + ADD COLUMN IF NOT EXISTS "responseTimeMinutes" INTEGER NOT NULL DEFAULT 45; + +ALTER TABLE "TourBooking" + ADD COLUMN IF NOT EXISTS "responseDeadlineAt" TIMESTAMP(3); + +UPDATE "TourBooking" AS booking +SET "responseDeadlineAt" = + booking."createdAt" + make_interval(mins => COALESCE(tour."responseTimeMinutes", 45)) +FROM "Tour" AS tour +WHERE booking."tourId" = tour."id" + AND booking."responseDeadlineAt" IS NULL; diff --git a/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql b/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql new file mode 100644 index 0000000..bdd62d5 --- /dev/null +++ b/backend/prisma/migrations/20260607193000_user_email_change_account_delete_codes/migration.sql @@ -0,0 +1,10 @@ +ALTER TYPE "AuthCodeType" ADD VALUE IF NOT EXISTS 'ACCOUNT_DELETE'; + +ALTER TABLE "User" +ADD COLUMN "pendingEmail" TEXT, +ADD COLUMN "emailChangeResendCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "emailChangeRequestedAt" TIMESTAMP(3), +ADD COLUMN "accountDeleteResendCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "accountDeleteRequestedAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX "User_pendingEmail_key" ON "User"("pendingEmail"); diff --git a/backend/prisma/migrations/20260612100000_publish_approved_agency_tours/migration.sql b/backend/prisma/migrations/20260612100000_publish_approved_agency_tours/migration.sql new file mode 100644 index 0000000..837a135 --- /dev/null +++ b/backend/prisma/migrations/20260612100000_publish_approved_agency_tours/migration.sql @@ -0,0 +1,40 @@ +-- Backfill older approved agency tours so the mobile/home public APIs can see them. +UPDATE "TourAgency" +SET + "active" = TRUE, + "approvedAt" = COALESCE("approvedAt", "updatedAt"), + "rejectedAt" = NULL +WHERE "approvalStatus" = 'approved'; + +UPDATE "Tour" +SET + "active" = TRUE, + "badge" = CASE + WHEN lower(COALESCE("badge", '')) = 'popular' THEN 'Popular' + ELSE 'Latest' + END, + "approvedAt" = COALESCE("approvedAt", "updatedAt"), + "rejectedAt" = NULL +WHERE "approvalStatus" = 'approved'; + +UPDATE "TourAgency" AS agency +SET "toursCount" = counts."approvedCount" +FROM ( + SELECT "agencyId", COUNT(*)::INTEGER AS "approvedCount" + FROM "Tour" + WHERE "agencyId" IS NOT NULL + AND "active" = TRUE + AND "approvalStatus" = 'approved' + GROUP BY "agencyId" +) AS counts +WHERE agency."id" = counts."agencyId"; + +UPDATE "TourAgency" AS agency +SET "toursCount" = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM "Tour" AS tour + WHERE tour."agencyId" = agency."id" + AND tour."active" = TRUE + AND tour."approvalStatus" = 'approved' +); diff --git a/backend/prisma/migrations/20260612101500_backfill_tour_images/migration.sql b/backend/prisma/migrations/20260612101500_backfill_tour_images/migration.sql new file mode 100644 index 0000000..885f52b --- /dev/null +++ b/backend/prisma/migrations/20260612101500_backfill_tour_images/migration.sql @@ -0,0 +1,17 @@ +-- Ensure approved tours without uploaded covers still render with a useful image in mobile/web. +UPDATE "Tour" +SET "imageUrl" = 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?auto=format&fit=crop&w=1400&q=80' +WHERE "approvalStatus" = 'approved' + AND ("imageUrl" IS NULL OR trim("imageUrl") = '') + AND ( + lower(COALESCE("title", '')) LIKE '%dubai%' + OR lower(COALESCE("title", '')) LIKE '%dubay%' + OR lower(COALESCE("city", '')) LIKE '%dubai%' + OR lower(COALESCE("city", '')) LIKE '%dubay%' + OR lower(COALESCE("city", '')) LIKE '%uae%' + ); + +UPDATE "Tour" +SET "imageUrl" = 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=1400&q=80' +WHERE "approvalStatus" = 'approved' + AND ("imageUrl" IS NULL OR trim("imageUrl") = ''); diff --git a/backend/prisma/migrations/20260612103000_tour_package_details/migration.sql b/backend/prisma/migrations/20260612103000_tour_package_details/migration.sql new file mode 100644 index 0000000..c9145c8 --- /dev/null +++ b/backend/prisma/migrations/20260612103000_tour_package_details/migration.sql @@ -0,0 +1,37 @@ +-- Structured package details for agency tours. All new fields are nullable/defaulted +-- so existing tours stay intact and can be completed later from the agency panel. +ALTER TABLE "Tour" + ADD COLUMN IF NOT EXISTS "priceCurrency" TEXT, + ADD COLUMN IF NOT EXISTS "priceBasis" TEXT, + ADD COLUMN IF NOT EXISTS "departureCity" TEXT, + ADD COLUMN IF NOT EXISTS "destinationCountry" TEXT, + ADD COLUMN IF NOT EXISTS "tourGroup" TEXT, + ADD COLUMN IF NOT EXISTS "nights" INTEGER, + ADD COLUMN IF NOT EXISTS "hotelIncluded" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "hotelName" TEXT, + ADD COLUMN IF NOT EXISTS "hotelCategory" TEXT, + ADD COLUMN IF NOT EXISTS "hotelLocation" TEXT, + ADD COLUMN IF NOT EXISTS "roomType" TEXT, + ADD COLUMN IF NOT EXISTS "mealPlan" TEXT, + ADD COLUMN IF NOT EXISTS "mealPlanLabel" TEXT, + ADD COLUMN IF NOT EXISTS "childPolicy" TEXT, + ADD COLUMN IF NOT EXISTS "flightSeatStatus" TEXT, + ADD COLUMN IF NOT EXISTS "availabilityStatus" TEXT, + ADD COLUMN IF NOT EXISTS "instantConfirmation" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "stopSale" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "promo" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "priceIncludes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS "priceExcludes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +UPDATE "Tour" +SET + "priceIncludes" = COALESCE("priceIncludes", ARRAY[]::TEXT[]), + "priceExcludes" = COALESCE("priceExcludes", ARRAY[]::TEXT[]), + "hotelIncluded" = COALESCE("hotelIncluded", FALSE), + "instantConfirmation" = COALESCE("instantConfirmation", FALSE), + "stopSale" = COALESCE("stopSale", FALSE), + "promo" = COALESCE("promo", FALSE); + +CREATE INDEX IF NOT EXISTS "Tour_mealPlan_idx" ON "Tour"("mealPlan"); +CREATE INDEX IF NOT EXISTS "Tour_hotelCategory_idx" ON "Tour"("hotelCategory"); +CREATE INDEX IF NOT EXISTS "Tour_availabilityStatus_idx" ON "Tour"("availabilityStatus"); diff --git a/backend/prisma/migrations/20260613090000_agency_google_id/migration.sql b/backend/prisma/migrations/20260613090000_agency_google_id/migration.sql new file mode 100644 index 0000000..8883b2a --- /dev/null +++ b/backend/prisma/migrations/20260613090000_agency_google_id/migration.sql @@ -0,0 +1,3 @@ +-- AgencyAccount.googleId (prod DB da allaqachon bor bo'lishi mumkin) +ALTER TABLE "AgencyAccount" ADD COLUMN IF NOT EXISTS "googleId" TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS "AgencyAccount_googleId_key" ON "AgencyAccount"("googleId"); diff --git a/backend/prisma/migrations/20260613110000_user_push_token/migration.sql b/backend/prisma/migrations/20260613110000_user_push_token/migration.sql new file mode 100644 index 0000000..1f8ecfd --- /dev/null +++ b/backend/prisma/migrations/20260613110000_user_push_token/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "expoPushToken" TEXT; diff --git a/backend/prisma/migrations/20260616100000_tour_v2_fields/migration.sql b/backend/prisma/migrations/20260616100000_tour_v2_fields/migration.sql new file mode 100644 index 0000000..b8ca6f3 --- /dev/null +++ b/backend/prisma/migrations/20260616100000_tour_v2_fields/migration.sql @@ -0,0 +1,7 @@ +-- Tour form v2: from/to dropdowns, nights+days, hotel/flight checkboxes, discount, price basis people, price lock +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "days" INTEGER; +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "flightIncluded" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "discount" TEXT; +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "priceBasisPeople" INTEGER; +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "priceLockMinutes" INTEGER; +ALTER TABLE "Tour" ADD COLUMN IF NOT EXISTS "priceLockUntil" TIMESTAMP(3); diff --git a/backend/prisma/migrations/20260620100000_user_role/migration.sql b/backend/prisma/migrations/20260620100000_user_role/migration.sql new file mode 100644 index 0000000..bf6601d --- /dev/null +++ b/backend/prisma/migrations/20260620100000_user_role/migration.sql @@ -0,0 +1,2 @@ +-- User role (traveler | partner | admin) — admin panel JWT auth uchun +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "role" TEXT NOT NULL DEFAULT 'traveler'; diff --git a/backend/prisma/migrations/20260620110000_feedback_status/migration.sql b/backend/prisma/migrations/20260620110000_feedback_status/migration.sql new file mode 100644 index 0000000..7792931 --- /dev/null +++ b/backend/prisma/migrations/20260620110000_feedback_status/migration.sql @@ -0,0 +1,2 @@ +-- Feedback status (new | resolved) — admin feedback inbox uchun +ALTER TABLE "Feedback" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'new'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ff16a6b..5d4d3b9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,6 +16,8 @@ enum AuthProvider { enum AuthCodeType { EMAIL_VERIFICATION PASSWORD_RESET + EMAIL_CHANGE + ACCOUNT_DELETE } enum FeedbackCategory { @@ -27,21 +29,28 @@ enum FeedbackCategory { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String lastName String? bio String? avatarUrl String? - email String @unique + email String @unique + pendingEmail String? @unique password String? - googleId String? @unique - authProvider AuthProvider @default(LOCAL) - emailVerified Boolean @default(false) + googleId String? @unique + authProvider AuthProvider @default(LOCAL) + emailVerified Boolean @default(false) emailVerifiedAt DateTime? lastLoginAt DateTime? - blocked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + blocked Boolean @default(false) + role String @default("traveler") + expoPushToken String? + emailChangeResendCount Int @default(0) + emailChangeRequestedAt DateTime? + accountDeleteResendCount Int @default(0) + accountDeleteRequestedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt trips Trip[] authCodes AuthCode[] preference UserPreference? @@ -66,18 +75,22 @@ model AuthCode { } model AgencyAccount { - id String @id @default(cuid()) - email String @unique - passwordHash String - emailVerified Boolean @default(false) - emailVerifiedAt DateTime? - status String @default("pending") - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - authCodes AgencyAuthCode[] - applications AgencyApplication[] - agencies TourAgency[] + id String @id @default(cuid()) + email String @unique + pendingEmail String? @unique + passwordHash String + googleId String? @unique + emailVerified Boolean @default(false) + emailVerifiedAt DateTime? + emailChangeResendCount Int @default(0) + emailChangeRequestedAt DateTime? + status String @default("pending") + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + authCodes AgencyAuthCode[] + applications AgencyApplication[] + agencies TourAgency[] @@index([status]) @@index([emailVerified]) @@ -98,30 +111,31 @@ model AgencyAuthCode { } model AgencyApplication { - id String @id @default(cuid()) + id String @id @default(cuid()) accountId String - account AgencyAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + account AgencyAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) companyName String legalName String? contactPerson String phone String email String city String - country String @default("Global") + country String @default("Global") website String? telegram String? instagram String? - serviceTypes String[] @default([]) + serviceTypes String[] @default([]) description String + imageUrl String? documents Json? - status String @default("draft") + status String @default("draft") adminNote String? submittedAt DateTime? reviewedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([accountId]) @@index([agencyId]) @@ -129,49 +143,49 @@ model AgencyApplication { } model Destination { - id String @id @default(cuid()) - slug String @unique - name String - region String - description String - imageUrl String - rating Float - reviewCount Int - categories String[] - tags String[] - budgetDaily Int - midDaily Int - luxuryDaily Int - trainPrice Int @default(0) - trainDuration String @default("") - busPrice Int @default(0) - busDuration String @default("") - flightPrice Int @default(0) - flightDuration String @default("") - landmarks Json - hotels Json - foodBudget Int @default(0) - foodMid Int @default(0) - foodLuxury Int @default(0) - bestSeasons String[] - minDays Int - maxDays Int - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.65) - verifiedBy String? - seasonality Json? - openingHours Json? - priceUpdatedAt DateTime? - coverageTier String @default("starter") - createdAt DateTime @default(now()) + id String @id @default(cuid()) + slug String @unique + name String + region String + description String + imageUrl String + rating Float + reviewCount Int + categories String[] + tags String[] + budgetDaily Int + midDaily Int + luxuryDaily Int + trainPrice Int @default(0) + trainDuration String @default("") + busPrice Int @default(0) + busDuration String @default("") + flightPrice Int @default(0) + flightDuration String @default("") + landmarks Json + hotels Json + foodBudget Int @default(0) + foodMid Int @default(0) + foodLuxury Int @default(0) + bestSeasons String[] + minDays Int + maxDays Int + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.65) + verifiedBy String? + seasonality Json? + openingHours Json? + priceUpdatedAt DateTime? + coverageTier String @default("starter") + createdAt DateTime @default(now()) } model Trip { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) title String totalCost Int perPersonCost Int @@ -181,8 +195,8 @@ model Trip { travelers Int duration Int planData Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt tripReviews TripReview[] } @@ -197,37 +211,37 @@ model UserPreference { } model Poi { - id String @id @default(cuid()) - name String - city String - slug String - type String - subtype String? - lat Float - lng Float - info String - description String? - imageUrl String? - rating Float? - ratingCount Int? - priceLevel Int? - price Int? - icon String - openingHours Json? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.55) - verifiedBy String? + id String @id @default(cuid()) + name String + city String + slug String + type String + subtype String? + lat Float + lng Float + info String + description String? + imageUrl String? + rating Float? + ratingCount Int? + priceLevel Int? + price Int? + icon String + openingHours Json? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.55) + verifiedBy String? duplicateGroupId String? - priceUpdatedAt DateTime? - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.7) - landingSortOrder Int @default(0) - landingActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + priceUpdatedAt DateTime? + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.7) + landingSortOrder Int @default(0) + landingActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt wishlistItems WishlistItem[] @@ -242,42 +256,42 @@ model Poi { } model TransportProvider { - id String @id @default(cuid()) - slug String @unique - name String - type String - website String? - supportPhone String? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.65) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - routes TransportRoute[] + id String @id @default(cuid()) + slug String @unique + name String + type String + website String? + supportPhone String? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.65) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + routes TransportRoute[] } model TransportRoute { - id String @id @default(cuid()) - fromCity String - toCity String - mode String - providerId String? - provider TransportProvider? @relation(fields: [providerId], references: [id], onDelete: SetNull) - priceMin Int - priceMax Int + id String @id @default(cuid()) + fromCity String + toCity String + mode String + providerId String? + provider TransportProvider? @relation(fields: [providerId], references: [id], onDelete: SetNull) + priceMin Int + priceMax Int durationMinutes Int - distanceKm Float? - scheduleNote String - bookingUrl String? - source String @default("manual") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.6) - whyRecommended String? - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + distanceKm Float? + scheduleNote String + bookingUrl String? + source String @default("manual") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.6) + whyRecommended String? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([fromCity, toCity]) @@index([mode]) @@ -285,98 +299,99 @@ model TransportRoute { } model CityPack { - id String @id @default(cuid()) - city String @unique - version Int @default(1) - offlineReady Boolean @default(false) - poiCount Int @default(0) - destinationCount Int @default(0) - transportRouteCount Int @default(0) - emergencyContacts Json? - transportNotes Json? - sourceSummary Json? - updatedAt DateTime @updatedAt - createdAt DateTime @default(now()) + id String @id @default(cuid()) + city String @unique + version Int @default(1) + offlineReady Boolean @default(false) + poiCount Int @default(0) + destinationCount Int @default(0) + transportRouteCount Int @default(0) + emergencyContacts Json? + transportNotes Json? + sourceSummary Json? + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) } model HomeHeroSlide { - id String @id @default(cuid()) - slug String @unique + id String @id @default(cuid()) + slug String @unique title String subtitle String? imageUrl String actionUrl String? placeSlug String? - sortOrder Int @default(0) - active Boolean @default(true) - source String @default("admin") + sortOrder Int @default(0) + active Boolean @default(true) + source String @default("admin") sourceUrl String? lastVerifiedAt DateTime? - confidenceScore Float @default(0.8) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + confidenceScore Float @default(0.8) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([active, sortOrder]) } model TravelerStory { - id String @id @default(cuid()) - slug String @unique + id String @id @default(cuid()) + slug String @unique quote String authorName String authorRole String avatar String? avatarColor String? - rating Int @default(5) - sortOrder Int @default(0) - active Boolean @default(true) - source String @default("admin") + rating Int @default(5) + sortOrder Int @default(0) + active Boolean @default(true) + source String @default("admin") sourceUrl String? lastVerifiedAt DateTime? - confidenceScore Float @default(0.8) - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.8) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + confidenceScore Float @default(0.8) + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.8) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([active, sortOrder]) @@index([featured]) } model TourAgency { - id String @id @default(cuid()) - slug String @unique - ownerAccountId String? - ownerAccount AgencyAccount? @relation(fields: [ownerAccountId], references: [id], onDelete: SetNull) - name String - city String - description String? - specialty String - rating Float @default(0) - reviews Int @default(0) - toursCount Int @default(0) - phone String? - website String? - imageUrl String? - active Boolean @default(true) - source String @default("admin") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.7) - featured Boolean @default(false) - manualBoost Float @default(0) - qualityScore Float @default(0.7) - landingSortOrder Int @default(0) - approvalStatus String @default("approved") - approvedAt DateTime? - rejectedAt DateTime? - adminNote String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - tours Tour[] - applications AgencyApplication[] - bookings TourBooking[] + id String @id @default(cuid()) + slug String @unique + ownerAccountId String? + ownerAccount AgencyAccount? @relation(fields: [ownerAccountId], references: [id], onDelete: SetNull) + name String + city String + description String? + specialty String + rating Float @default(0) + reviews Int @default(0) + toursCount Int @default(0) + phone String? + telegram String? + website String? + imageUrl String? + active Boolean @default(true) + source String @default("admin") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.7) + featured Boolean @default(false) + manualBoost Float @default(0) + qualityScore Float @default(0.7) + landingSortOrder Int @default(0) + approvalStatus String @default("approved") + approvedAt DateTime? + rejectedAt DateTime? + adminNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tours Tour[] + applications AgencyApplication[] + bookings TourBooking[] @@index([active]) @@index([city]) @@ -405,35 +420,63 @@ model LandingInteraction { } model Tour { - id String @id @default(cuid()) - slug String @unique - title String - city String - subtitle String - description String? - duration String - price String? - priceMin Int? - rating Float @default(0) - badge String @default("Latest") - imageUrl String? - itinerary Json? - highlights String[] @default([]) - active Boolean @default(true) - agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) - approvalStatus String @default("approved") - submittedAt DateTime? - approvedAt DateTime? - rejectedAt DateTime? - adminNote String? - source String @default("admin") - sourceUrl String? - lastVerifiedAt DateTime? - confidenceScore Float @default(0.7) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bookings TourBooking[] + id String @id @default(cuid()) + slug String @unique + title String + city String + subtitle String + description String? + duration String + responseTimeMinutes Int @default(45) + price String? + priceMin Int? + priceCurrency String? + priceBasis String? + rating Float @default(0) + badge String @default("Latest") + imageUrl String? + itinerary Json? + highlights String[] @default([]) + departureCity String? + destinationCountry String? + tourGroup String? + nights Int? + hotelIncluded Boolean @default(false) + hotelName String? + hotelCategory String? + hotelLocation String? + roomType String? + mealPlan String? + mealPlanLabel String? + childPolicy String? + flightSeatStatus String? + availabilityStatus String? + instantConfirmation Boolean @default(false) + stopSale Boolean @default(false) + promo Boolean @default(false) + priceIncludes String[] @default([]) + priceExcludes String[] @default([]) + days Int? + flightIncluded Boolean @default(false) + discount String? + priceBasisPeople Int? + priceLockMinutes Int? + priceLockUntil DateTime? + active Boolean @default(true) + agencyId String? + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + approvalStatus String @default("approved") + submittedAt DateTime? + approvedAt DateTime? + rejectedAt DateTime? + adminNote String? + source String @default("admin") + sourceUrl String? + lastVerifiedAt DateTime? + confidenceScore Float @default(0.7) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bookings TourBooking[] @@index([active]) @@index([badge]) @@ -445,31 +488,32 @@ model Tour { } model TourBooking { - id String @id @default(cuid()) - tourId String - tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) - agencyId String? - agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) - userId String? - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - customerName String - customerEmail String - customerPhone String? - travelers Int @default(1) - travelDate DateTime? - message String? - status String @default("pending") - totalEstimate Int? - currency String @default("USD") - source String @default("mobile") - agencyNote String? - adminNote String? - confirmedAt DateTime? - rejectedAt DateTime? - cancelledAt DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + tourId String + tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) + agencyId String? + agency TourAgency? @relation(fields: [agencyId], references: [id], onDelete: SetNull) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + customerName String + customerEmail String? + customerPhone String? + travelers Int @default(1) + travelDate DateTime? + message String? + status String @default("pending") + responseDeadlineAt DateTime? + totalEstimate Int? + currency String @default("USD") + source String @default("mobile") + agencyNote String? + adminNote String? + confirmedAt DateTime? + rejectedAt DateTime? + cancelledAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([tourId]) @@index([agencyId, status]) @@ -479,20 +523,20 @@ model TourBooking { } model WishlistItem { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - poiId String? - poi Poi? @relation(fields: [poiId], references: [id], onDelete: SetNull) - name String - city String - slug String - type String - icon String - savedAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + poiId String? + poi Poi? @relation(fields: [poiId], references: [id], onDelete: SetNull) + name String + city String + slug String + type String + icon String + savedAt DateTime @default(now()) - @@index([userId, savedAt]) @@unique([userId, slug]) + @@index([userId, savedAt]) } model Feedback { @@ -505,6 +549,7 @@ model Feedback { contactEmail String? platform String? appVersion String? + status String @default("new") createdAt DateTime @default(now()) @@index([userId]) diff --git a/backend/server.js b/backend/server.js index ba538f4..0c51ed6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,4 +1,4 @@ -require('dotenv').config(); +require('dotenv').config({ override: true }); if (process.env.NODE_ENV !== 'production') { process.env.DATABASE_URL ||= 'postgresql://travelorai:travelorai_pass@localhost:5433/travelorai_db'; diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 5eb4d3f..ebd1c35 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -3,6 +3,8 @@ const { success, error } = require('../utils/response'); const { adminReviewSchema } = require('../schemas/agency.schema'); const { bookingStatusSchema } = require('../schemas/booking.schema'); const { formatBooking } = require('./bookings.controller'); +const { resolveTourImageUrl } = require('../utils/tourImage'); +const { sendPushNotification } = require('../services/push.service'); const crypto = require('crypto'); const fs = require('fs/promises'); const path = require('path'); @@ -20,6 +22,10 @@ function slugify(text) { .substring(0, 80); } +function normalizeTourBadge(value) { + return String(value || '').trim().toLowerCase() === 'popular' ? 'Popular' : 'Latest'; +} + async function uniqueSlug(base) { const safe = base || ('poi-' + Date.now()); let slug = safe; @@ -908,7 +914,9 @@ async function approveAgencyApplication(req, res) { description: application.description, specialty, phone: application.phone, + telegram: application.telegram || null, website: application.website, + imageUrl: application.imageUrl, active: true, source: 'agency_portal', confidenceScore: 0.85, @@ -1018,6 +1026,7 @@ async function getAdminTours(req, res) { async function approveTour(req, res) { try { const { adminNote } = adminReviewSchema.parse(req.body || {}); + const now = new Date(); const existing = await prisma.tour.findUnique({ where: { id: req.params.id }, include: { agency: true }, @@ -1029,7 +1038,9 @@ async function approveTour(req, res) { data: { approvalStatus: 'approved', active: true, - approvedAt: new Date(), + badge: normalizeTourBadge(existing.badge), + imageUrl: resolveTourImageUrl(existing), + approvedAt: now, rejectedAt: null, adminNote: adminNote || null, }, @@ -1040,6 +1051,10 @@ async function approveTour(req, res) { await prisma.tourAgency.update({ where: { id: tour.agencyId }, data: { + active: true, + approvalStatus: 'approved', + approvedAt: tour.agency?.approvedAt || now, + rejectedAt: null, toursCount: await prisma.tour.count({ where: { agencyId: tour.agencyId, active: true, approvalStatus: 'approved' }, }), @@ -1127,6 +1142,27 @@ async function updateBookingStatus(req, res) { include: { tour: true, agency: true }, }); + // Foydalanuvchiga push (token bor bo'lsa) — fire-and-forget + if (updated.userId) { + const labels = { confirmed: 'qabul qilindi', rejected: 'rad etildi', cancelled: 'bekor qilindi', completed: 'yakunlandi' }; + const label = labels[updated.status]; + if (label) { + prisma.user + .findUnique({ where: { id: updated.userId }, select: { expoPushToken: true } }) + .then((user) => { + if (user?.expoPushToken) { + return sendPushNotification({ + to: user.expoPushToken, + title: 'Booking holati yangilandi', + body: `${updated.tour?.title || 'Tur'} bo‘yicha so‘rovingiz ${label}.`, + data: { type: 'booking_status', bookingId: updated.id, status: updated.status }, + }); + } + }) + .catch(() => {}); + } + } + return success(res, { booking: formatBooking(updated) }); } catch (err) { return error(res, err.errors?.[0]?.message || err.message, 400); @@ -1438,7 +1474,133 @@ async function deleteFeedback(req, res) { } } +// ===== Yangi premium admin panel endpointlari ===== +async function updateFeedbackStatus(req, res) { + try { + const next = String(req.body?.status || '').toLowerCase() === 'resolved' ? 'resolved' : 'new'; + const item = await prisma.feedback.update({ where: { id: req.params.id }, data: { status: next } }); + return success(res, { id: item.id, status: item.status }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function deleteAdminTour(req, res) { + try { + await prisma.tour.delete({ where: { id: req.params.id } }); + return success(res, { deleted: true }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function getReviews(req, res) { + try { + const items = await prisma.tripReview.findMany({ + orderBy: { createdAt: 'desc' }, + take: 200, + include: { + user: { select: { name: true, email: true } }, + trip: { select: { title: true } }, + }, + }); + return success(res, { + items: items.map((r) => ({ + id: r.id, + rating: r.rating, + comment: r.comment, + createdAt: r.createdAt, + author: r.user?.name || 'Foydalanuvchi', + authorEmail: r.user?.email || null, + tourTitle: r.trip?.title || 'Sayohat', + })), + total: items.length, + }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function deleteReview(req, res) { + try { + await prisma.tripReview.delete({ where: { id: req.params.id } }); + return success(res, { deleted: true }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function getReports(req, res) { + try { + const COMMISSION = 0.05; + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [bookings, statusGroups, paidCount, last30] = await Promise.all([ + prisma.tourBooking.findMany({ + select: { totalEstimate: true, status: true, tourId: true, tour: { select: { title: true, city: true } } }, + }), + prisma.tourBooking.groupBy({ by: ['status'], _count: { _all: true } }), + prisma.tourBooking.count({ where: { status: { in: ['confirmed', 'completed'] } } }), + prisma.tourBooking.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), + ]); + + const paid = bookings.filter((b) => b.status === 'confirmed' || b.status === 'completed'); + const totalRevenue = paid.reduce((s, b) => s + (b.totalEstimate || 0), 0); + const tourMap = {}; + for (const b of paid) { + if (!tourMap[b.tourId]) tourMap[b.tourId] = { title: b.tour?.title || 'Tur', city: b.tour?.city || '', bookings: 0, revenue: 0 }; + tourMap[b.tourId].bookings += 1; + tourMap[b.tourId].revenue += b.totalEstimate || 0; + } + const topTours = Object.values(tourMap).sort((a, b) => b.revenue - a.revenue).slice(0, 8); + + return success(res, { + totalRevenue, + commission: Math.round(totalRevenue * COMMISSION), + commissionRate: COMMISSION, + paidBookings: paidCount, + last30Days: last30, + byStatus: statusGroups.map((g) => ({ status: g.status, count: g._count._all })), + topTours, + currency: 'USD', + }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function createPartner(req, res) { + try { + const bcrypt = require('bcryptjs'); + const email = String(req.body?.email || '').trim().toLowerCase(); + const password = String(req.body?.password || ''); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || password.length < 8) { + return error(res, 'Email va kamida 8 belgili parol majburiy.', 422); + } + const exists = await prisma.agencyAccount.findUnique({ where: { email } }); + if (exists) return error(res, 'Bu email bilan hamkor allaqachon mavjud.', 409); + const userExists = await prisma.user.findUnique({ where: { email } }); + if (userExists) return error(res, 'Bu email foydalanuvchi sifatida ro‘yxatdan o‘tgan.', 409); + + const passwordHash = await bcrypt.hash(password, 10); + const account = await prisma.agencyAccount.create({ + data: { email, passwordHash, status: 'approved', emailVerified: true, emailVerifiedAt: new Date() }, + }); + return success(res, { id: account.id, email: account.email, status: account.status }, 201); + } catch (err) { + return error(res, err.message, 500); + } +} + +// Admin panel sessiyasini tekshirish (AdminGate uchun) — JWT(role=admin) yetarli. +async function adminMe(req, res) { + const u = req.adminUser || {}; + return success(res, { user: { name: u.username || 'Admin', username: u.username || 'admin', email: u.email || '', role: 'admin' } }); +} + module.exports = { + adminMe, getStats, getUsers, getUser, blockUser, deleteUser, getTrips, getTrip, deleteTrip, @@ -1452,4 +1614,5 @@ module.exports = { getTransportProviders, createTransportProvider, updateTransportProvider, deleteTransportProvider, getTransportRoutes, createTransportRoute, updateTransportRoute, deleteTransportRoute, getFeedback, deleteFeedback, + updateFeedbackStatus, deleteAdminTour, getReviews, deleteReview, getReports, createPartner, }; diff --git a/backend/src/controllers/agency.controller.js b/backend/src/controllers/agency.controller.js index 5abf332..7f5268a 100644 --- a/backend/src/controllers/agency.controller.js +++ b/backend/src/controllers/agency.controller.js @@ -3,11 +3,18 @@ const crypto = require('crypto'); const { prisma } = require('../config/database'); const { success, error } = require('../utils/response'); const { signAgencyToken } = require('../utils/agencyJwt'); -const { sendVerificationCodeEmail } = require('../services/email.service'); +const { verifyGoogleIdToken } = require('../services/auth.service'); +const { sendPushNotification } = require('../services/push.service'); +const { sendEmailChangeCodeEmail, sendEmailChangedNoticeEmail, sendVerificationCodeEmail } = require('../services/email.service'); +const { materializeDataImage } = require('../utils/dataImage'); +const { resolveTourImageUrl } = require('../utils/tourImage'); const { bookingStatusSchema } = require('../schemas/booking.schema'); const { formatBooking } = require('./bookings.controller'); const { applicationSchema, + emailChangeConfirmSchema, + emailChangeRequestSchema, + googleAuthSchema, loginSchema, registerSchema, tourSchema, @@ -15,6 +22,8 @@ const { } = require('../schemas/agency.schema'); const CODE_EXPIRES_MINUTES = Number(process.env.AGENCY_CODE_EXPIRES_MINUTES || 10); +const EMAIL_CHANGE_MAX_RESENDS = 3; +const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL || 'support@travelorai.local'; function slugify(text) { return String(text || '') @@ -40,7 +49,10 @@ function publicAccount(account) { return { id: account.id, email: account.email, + pendingEmail: account.pendingEmail || null, emailVerified: account.emailVerified, + emailChangeResendCount: account.emailChangeResendCount || 0, + emailChangeResendsRemaining: Math.max(0, EMAIL_CHANGE_MAX_RESENDS - Number(account.emailChangeResendCount || 0)), status: account.status, createdAt: account.createdAt, updatedAt: account.updatedAt, @@ -68,6 +80,7 @@ function publicAgency(agency) { reviews: agency.reviews, toursCount: agency.toursCount, phone: agency.phone, + telegram: agency.telegram, website: agency.website, imageUrl: agency.imageUrl, active: agency.active, @@ -111,14 +124,14 @@ async function uniqueTourSlug(base, currentId) { return slug; } -async function issueAgencyCode(account) { +async function issueAgencyCode(account, type = 'EMAIL_VERIFICATION') { const code = generateCode(); const expiresAt = new Date(Date.now() + CODE_EXPIRES_MINUTES * 60 * 1000); await prisma.agencyAuthCode.updateMany({ where: { accountId: account.id, - type: 'EMAIL_VERIFICATION', + type, usedAt: null, }, data: { usedAt: new Date() }, @@ -127,27 +140,54 @@ async function issueAgencyCode(account) { await prisma.agencyAuthCode.create({ data: { accountId: account.id, - type: 'EMAIL_VERIFICATION', + type, codeHash: hashCode(code), expiresAt, }, }); - const delivery = await sendVerificationCodeEmail({ - email: account.email, - name: account.email, - code, - expiresInMinutes: CODE_EXPIRES_MINUTES, - }); - - return delivery; + const sendFn = + type === 'EMAIL_CHANGE' + ? () => + sendEmailChangeCodeEmail({ + email: account.email, + name: account.email, + newEmail: account.pendingEmail, + code, + expiresInMinutes: CODE_EXPIRES_MINUTES, + }) + : () => + sendVerificationCodeEmail({ + email: account.email, + name: account.email, + code, + expiresInMinutes: CODE_EXPIRES_MINUTES, + }); + + // Emailni BLOKLAMASDAN yuboramiz (Gmail SMTP 2-13s olishi mumkin) — javobni + // kutdirib qo'ymaymiz; kod allaqachon bazaga yozilgan. Xato bo'lsa logga yozamiz. + Promise.resolve() + .then(sendFn) + .catch((err) => + require('../config/logger').logger.error('Agency email send failed (async)', { + type, + email: account.email, + message: err.message, + }) + ); + + const willSendEmail = Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT); + return { + delivery: willSendEmail ? 'smtp' : 'log', + ...(process.env.NODE_ENV !== 'production' && !willSendEmail ? { devCode: code } : {}), + }; } -async function consumeAgencyCode(accountId, code) { +async function consumeAgencyCode(accountId, code, type = 'EMAIL_VERIFICATION') { const item = await prisma.agencyAuthCode.findFirst({ where: { accountId, - type: 'EMAIL_VERIFICATION', + type, usedAt: null, expiresAt: { gt: new Date() }, }, @@ -163,6 +203,120 @@ async function consumeAgencyCode(accountId, code) { return true; } +async function requestEmailChange(req, res) { + try { + const input = emailChangeRequestSchema.parse(req.body || {}); + const newEmail = input.newEmail.toLowerCase(); + const account = req.agencyAccount; + if (newEmail === account.email) return error(res, 'Yangi email hozirgi emaildan farq qilishi kerak', 400); + if (account.pendingEmail) { + return error(res, 'Avval boshlangan email almashtirishni kod bilan tasdiqlang', 409); + } + + const conflict = await prisma.agencyAccount.findFirst({ + where: { + id: { not: account.id }, + OR: [{ email: newEmail }, { pendingEmail: newEmail }], + }, + }); + if (conflict) return error(res, 'Bu email boshqa agency akkauntida ishlatilgan', 409); + + const updated = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + pendingEmail: newEmail, + emailChangeResendCount: 0, + emailChangeRequestedAt: new Date(), + }, + }); + const delivery = await issueAgencyCode(updated, 'EMAIL_CHANGE'); + return success(res, { + account: publicAccount(updated), + delivery, + message: `Tasdiqlash kodi eski emailingizga (${updated.email}) yuborildi.`, + supportEmail: SUPPORT_EMAIL, + }); + } catch (err) { + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + +async function resendEmailChange(req, res) { + try { + const account = await prisma.agencyAccount.findUnique({ where: { id: req.agencyAccount.id } }); + if (!account?.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi', 404); + if (account.emailChangeResendCount >= EMAIL_CHANGE_MAX_RESENDS) { + return error(res, `Kod 3 marta qayta yuborildi. ${SUPPORT_EMAIL} orqali adminga murojaat qiling.`, 429, { + code: 'EMAIL_CHANGE_RESEND_LIMIT', + supportEmail: SUPPORT_EMAIL, + }); + } + + const updated = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { emailChangeResendCount: { increment: 1 } }, + }); + const delivery = await issueAgencyCode(updated, 'EMAIL_CHANGE'); + return success(res, { + account: publicAccount(updated), + delivery, + message: 'Kod eski emailga qayta yuborildi.', + supportEmail: SUPPORT_EMAIL, + }); + } catch (err) { + return error(res, err.message, 400); + } +} + +async function confirmEmailChange(req, res) { + try { + const input = emailChangeConfirmSchema.parse(req.body || {}); + const account = await prisma.agencyAccount.findUnique({ where: { id: req.agencyAccount.id } }); + if (!account?.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi', 404); + + const ok = await consumeAgencyCode(account.id, input.code, 'EMAIL_CHANGE'); + if (!ok) return error(res, 'Kod xato yoki muddati tugagan', 400); + + const conflict = await prisma.agencyAccount.findFirst({ + where: { id: { not: account.id }, email: account.pendingEmail }, + }); + if (conflict) return error(res, 'Yangi email boshqa akkaunt tomonidan band qilingan', 409); + + const [updated] = await prisma.$transaction([ + prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + email: account.pendingEmail, + pendingEmail: null, + emailChangeResendCount: 0, + emailChangeRequestedAt: null, + emailVerified: true, + emailVerifiedAt: new Date(), + // Xavfsizlik: eski Google identifikatorini uzamiz — aks holda eski + // Gmail bilan "Continue with Google" hisobga kiraverardi + googleId: null, + }, + }), + prisma.agencyApplication.updateMany({ + where: { accountId: account.id }, + data: { email: account.pendingEmail }, + }), + ]); + + // Xabarnoma: eski va yangi manzilga (javobni kutmaymiz) + sendEmailChangedNoticeEmail({ oldEmail: account.email, newEmail: updated.email }).catch(() => {}); + + const token = signAgencyToken({ id: updated.id, email: updated.email, role: 'agency' }); + return success(res, { + token, + account: publicAccount(updated), + message: 'Email muvaffaqiyatli almashtirildi.', + }); + } catch (err) { + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + async function getLatestApplication(accountId) { return prisma.agencyApplication.findFirst({ where: { accountId }, @@ -274,6 +428,12 @@ async function register(req, res) { return error(res, 'Bu email bilan agency akkaunt mavjud. Login qiling.', 409); } + // Cross-check: bu email foydalanuvchi akkaunti sifatida band bo'lmasin (bir email — bir rol). + const userAccount = await prisma.user.findUnique({ where: { email } }); + if (userAccount) { + return error(res, 'Bu email foydalanuvchi akkaunti sifatida ro‘yxatdan o‘tgan. Agentlik sifatida ro‘yxatdan o‘tib bo‘lmaydi.', 409); + } + const account = existing ? await prisma.agencyAccount.update({ where: { id: existing.id }, @@ -344,6 +504,59 @@ async function login(req, res) { } } + +async function googleAuth(req, res) { + try { + const input = googleAuthSchema.parse(req.body || {}); + const googleProfile = await verifyGoogleIdToken(input.idToken); + let account = await prisma.agencyAccount.findFirst({ + where: { + OR: [{ googleId: googleProfile.googleId }, { email: googleProfile.email }], + }, + }); + + if (account?.status === 'blocked') { + return error(res, 'Agency akkaunt bloklangan', 403); + } + + if (!account) { + const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); + account = await prisma.agencyAccount.create({ + data: { + email: googleProfile.email, + googleId: googleProfile.googleId, + passwordHash, + emailVerified: true, + emailVerifiedAt: new Date(), + lastLoginAt: new Date(), + status: 'pending', + }, + }); + } else { + account = await prisma.agencyAccount.update({ + where: { id: account.id }, + data: { + googleId: account.googleId || googleProfile.googleId, + emailVerified: true, + emailVerifiedAt: account.emailVerifiedAt || new Date(), + lastLoginAt: new Date(), + }, + }); + } + + const token = signAgencyToken({ id: account.id, email: account.email, role: 'agency' }); + return success(res, { token, account: publicAccount(account) }); + } catch (err) { + if (err.message === 'GOOGLE_AUDIENCE_MISMATCH') { + return error(res, 'Google client ID mos kelmadi.', 401); + } + if (err.message === 'GOOGLE_EMAIL_NOT_VERIFIED') { + return error(res, 'Google akkauntdagi email tasdiqlanmagan.', 401); + } + return error(res, err.errors?.[0]?.message || err.message, 400); + } +} + async function me(req, res) { try { const [application, agency] = await Promise.all([ @@ -375,6 +588,7 @@ async function me(req, res) { return acc; }, {}), bookingStats, + supportEmail: SUPPORT_EMAIL, }); } catch (err) { return error(res, err.message, 500); @@ -406,6 +620,7 @@ async function upsertApplication(req, res) { website: input.website || null, telegram: input.telegram || null, instagram: input.instagram || null, + imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null, status: existing?.status === 'pending' ? 'pending' : 'draft', }; @@ -495,7 +710,12 @@ async function createTour(req, res) { description: input.description || null, price: input.price || null, priceMin: input.priceMin ?? null, - imageUrl: input.imageUrl || null, + priceLockUntil: + input.priceLockMinutes && input.priceLockMinutes > 0 + ? new Date(Date.now() + input.priceLockMinutes * 60000) + : null, + imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null, + responseTimeMinutes: input.responseTimeMinutes, slug, agencyId: agency.id, source: 'agency_portal', @@ -529,7 +749,18 @@ async function updateTour(req, res) { ...(input.description !== undefined ? { description: input.description || null } : {}), ...(input.price !== undefined ? { price: input.price || null } : {}), ...(input.priceMin !== undefined ? { priceMin: input.priceMin ?? null } : {}), - ...(input.imageUrl !== undefined ? { imageUrl: input.imageUrl || null } : {}), + ...(input.priceLockMinutes !== undefined + ? { + priceLockUntil: + input.priceLockMinutes && input.priceLockMinutes > 0 + ? new Date(Date.now() + input.priceLockMinutes * 60000) + : null, + } + : {}), + ...(input.imageUrl !== undefined + ? { imageUrl: input.imageUrl ? await materializeDataImage(input.imageUrl, 'agency') : null } + : {}), + ...(input.responseTimeMinutes !== undefined ? { responseTimeMinutes: input.responseTimeMinutes } : {}), approvalStatus: nextStatus, active: false, submittedAt: nextStatus === 'pending_review' ? new Date() : existing.submittedAt, @@ -608,6 +839,40 @@ async function listBookings(req, res) { } } +const BOOKING_PUSH = { + confirmed: { + title: 'So‘rovingiz qabul qilindi 🎉', + body: (t) => `${t} bo‘yicha agentlik so‘rovingizni qabul qildi. Tez orada bog‘lanadi.`, + }, + rejected: { + title: 'So‘rov rad etildi', + body: (t) => `Afsuski, ${t} bo‘yicha so‘rovingiz rad etildi. Boshqa turlarni ko‘rib chiqing.`, + }, + cancelled: { + title: 'So‘rov bekor qilindi', + body: (t) => `${t} bo‘yicha so‘rov bekor qilindi.`, + }, + completed: { + title: 'Safaringiz yakunlandi ✅', + body: (t) => `${t} — sayohatingiz yakunlandi. Fikringizni bildiring!`, + }, +}; + +async function notifyBookingStatus(booking) { + if (!booking || !booking.userId) return; + const tpl = BOOKING_PUSH[booking.status]; + if (!tpl) return; + const user = await prisma.user.findUnique({ where: { id: booking.userId }, select: { expoPushToken: true } }); + if (!user || !user.expoPushToken) return; + const tourTitle = booking.tour && booking.tour.title ? booking.tour.title : 'Tur'; + await sendPushNotification({ + to: user.expoPushToken, + title: tpl.title, + body: tpl.body(tourTitle), + data: { type: 'booking_status', bookingId: booking.id, status: booking.status }, + }); +} + async function updateBookingStatus(req, res) { try { const agency = await ensureApprovedAgency(req, res); @@ -646,7 +911,7 @@ async function updateAgencyProfile(req, res) { const agency = await ensureApprovedAgency(req, res); if (!agency) return; const required = ['name', 'city', 'specialty']; - const nullable = ['description', 'phone', 'website', 'imageUrl']; + const nullable = ['description', 'phone', 'telegram', 'website']; const data = {}; for (const key of required) { if (req.body?.[key] !== undefined) { @@ -658,6 +923,11 @@ async function updateAgencyProfile(req, res) { for (const key of nullable) { if (req.body?.[key] !== undefined) data[key] = req.body[key] ? String(req.body[key]).trim() : null; } + if (req.body?.imageUrl !== undefined) { + data.imageUrl = req.body.imageUrl + ? await materializeDataImage(req.body.imageUrl, 'agency') + : null; + } if (data.name && data.name !== agency.name) data.slug = await uniqueAgencySlug(slugify(data.name), agency.id); const updated = await prisma.tourAgency.update({ @@ -671,8 +941,12 @@ async function updateAgencyProfile(req, res) { } module.exports = { + googleAuth, register, verifyEmail, + requestEmailChange, + resendEmailChange, + confirmEmailChange, login, me, getApplication, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index e0f81a6..095e337 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -18,6 +18,7 @@ const DEFAULT_PREFERENCES = { interests: ['tarixiy', 'madaniy'], updatedAt: null, }; +const MAX_SECURITY_CODE_REQUESTS = 3; function mapPreferences(pref) { if (!pref) return DEFAULT_PREFERENCES; @@ -52,40 +53,44 @@ async function register(req, res) { const normalizedEmail = normalizeEmail(email); const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); - if (existing?.emailVerified) { - return error(res, 'Bu email allaqachon ro\'yxatdan o\'tgan.', 409, { - authProvider: existing.googleId ? 'google' : 'local', + if (existing) { + const authProvider = existing.googleId && !existing.password ? 'google' : 'local'; + const message = + authProvider === 'google' + ? 'Bu Gmail Google orqali avval ro‘yxatdan o‘tgan. Google bilan kiring.' + : existing.emailVerified + ? 'Bu Gmail bilan avval ro‘yxatdan o‘tilgan. Kirish bo‘limidan foydalaning.' + : 'Bu Gmail bilan ro‘yxatdan o‘tilgan, lekin email hali tasdiqlanmagan. Kodni qayta yuboring.'; + + return error(res, message, 409, { + authProvider, + requiresVerification: authProvider === 'local' && !existing.emailVerified, + email: existing.email, }); } - if (existing?.googleId && !existing.password) { - return error(res, 'Bu email Google orqali ro\'yxatdan o\'tgan. Google bilan kiring.', 409, { - authProvider: 'google', - }); + // Cross-check: bu email agentlik akkaunti sifatida band bo'lmasin (bir email — bir rol). + const agencyAccount = await prisma.agencyAccount.findUnique({ where: { email: normalizedEmail } }); + if (agencyAccount?.emailVerified) { + return error( + res, + 'Bu email agentlik akkaunti sifatida ro‘yxatdan o‘tgan. Foydalanuvchi sifatida ro‘yxatdan o‘tib bo‘lmaydi.', + 409, + { accountType: 'agency' } + ); } const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS); - const user = existing - ? await prisma.user.update({ - where: { id: existing.id }, - data: { - name, - password: hashedPassword, - authProvider: AuthProvider.LOCAL, - emailVerified: false, - emailVerifiedAt: null, - }, - }) - : await prisma.user.create({ - data: { - name, - email: normalizedEmail, - password: hashedPassword, - authProvider: AuthProvider.LOCAL, - emailVerified: false, - }, - }); + const user = await prisma.user.create({ + data: { + name, + email: normalizedEmail, + password: hashedPassword, + authProvider: AuthProvider.LOCAL, + emailVerified: false, + }, + }); const codeResult = await issueAuthCode({ user, type: AuthCodeType.EMAIL_VERIFICATION }); @@ -98,9 +103,12 @@ async function register(req, res) { delivery: codeResult.delivery, ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), }, - existing ? 200 : 201 + 201 ); } catch (err) { + if (err.code === 'P2002') { + return error(res, 'Bu Gmail bilan avval ro‘yxatdan o‘tilgan. Kirish bo‘limidan foydalaning.', 409); + } return error(res, err.message, 500); } } @@ -219,6 +227,46 @@ async function login(req, res) { } } +// ===== Admin panel auth: login + parol → JWT (role=admin). Email/2FA yo'q. ===== +async function adminLogin(req, res) { + try { + const username = String(req.body.username || req.body.email || '').trim(); + const password = String(req.body.password || ''); + const expectedUser = process.env.ADMIN_USERNAME || 'admin'; + const expectedPass = process.env.ADMIN_PASSWORD || 'admin123'; + if (username !== expectedUser || password !== expectedPass) { + return error(res, 'Login yoki parol noto\'g\'ri.', 401); + } + const token = signToken({ role: 'admin', username, admin: true }); + return success(res, { token, user: { name: username, username, role: 'admin' } }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function adminLoginVerify(req, res) { + try { + const normalizedEmail = normalizeEmail(req.body.email || ''); + const code = String(req.body.code || '').trim(); + const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user || user.role !== 'admin') { + return error(res, 'Admin hisob topilmadi.', 403); + } + try { + await consumeAuthCode({ userId: user.id, type: AuthCodeType.EMAIL_VERIFICATION, code }); + } catch (codeErr) { + return mapCodeError(res, codeErr); + } + const updated = await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + return success(res, { + token: signToken({ id: updated.id, email: updated.email, role: 'admin' }), + user: { ...buildPublicUser(updated), role: 'admin' }, + }); + } catch (err) { + return error(res, err.message, 500); + } +} + async function forgotPassword(req, res) { try { const normalizedEmail = normalizeEmail(req.body.email); @@ -297,6 +345,18 @@ async function googleAuth(req, res) { })) || null; if (!user) { + // Cross-check: agentlik emaili Google orqali ham foydalanuvchi akkauntini yaratmasin. + const agencyAccount = await prisma.agencyAccount.findUnique({ + where: { email: normalizeEmail(googleProfile.email) }, + }); + if (agencyAccount?.emailVerified) { + return error( + res, + 'Bu email agentlik akkaunti sifatida ro‘yxatdan o‘tgan. Agentlik portalidan kiring.', + 409, + { accountType: 'agency' } + ); + } user = await prisma.user.create({ data: { name: googleProfile.name, @@ -331,6 +391,9 @@ async function googleAuth(req, res) { } if (err.message === 'GOOGLE_AUDIENCE_MISMATCH') { + try { + require('../config/logger').logger.warn('GOOGLE_AUDIENCE_MISMATCH', err.meta || {}); + } catch {} return error(res, 'Google client ID mos kelmadi. Android OAuth client (package + SHA-1) ni tekshiring.', 401, err.meta || undefined); } @@ -425,28 +488,155 @@ async function updatePreferences(req, res) { } } +async function verifyCurrentPassword(user, password) { + if (!user.password) return true; + if (!password) return false; + return bcrypt.compare(password, user.password); +} + +function securityRequestLimit(res, message) { + return error(res, message, 429, { + contactAdmin: true, + attemptsRemaining: 0, + }); +} + +async function requestEmailChange(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + + const newEmail = normalizeEmail(req.body.newEmail); + if (newEmail === user.email) return error(res, 'Yangi email joriy emaildan farq qilishi kerak.', 400); + + const emailOwner = await prisma.user.findFirst({ + where: { OR: [{ email: newEmail }, { pendingEmail: newEmail }], NOT: { id: user.id } }, + select: { id: true }, + }); + if (emailOwner) return error(res, 'Bu Gmail boshqa akkauntda ishlatilgan.', 409); + + const passwordOk = await verifyCurrentPassword(user, req.body.password); + if (!passwordOk) { + return error(res, user.password ? 'Parol noto‘g‘ri yoki kiritilmagan.' : 'Tasdiqlash amalga oshmadi.', 401, { + requiresPassword: Boolean(user.password), + }); + } + + const sameRequest = user.pendingEmail === newEmail; + const requestCount = sameRequest ? user.emailChangeResendCount : 0; + if (requestCount >= MAX_SECURITY_CODE_REQUESTS) { + return securityRequestLimit(res, 'Kod 3 marta so‘raldi. Emailni almashtirish uchun adminga murojaat qiling.'); + } + + const codeResult = await issueAuthCode({ user, type: AuthCodeType.EMAIL_CHANGE, newEmail }); + const nextCount = requestCount + 1; + await prisma.user.update({ + where: { id: user.id }, + data: { + pendingEmail: newEmail, + emailChangeResendCount: nextCount, + emailChangeRequestedAt: new Date(), + }, + }); + + return success(res, { + message: `Tasdiqlash kodi eski emailingizga yuborildi: ${user.email}`, + currentEmail: user.email, + pendingEmail: newEmail, + attemptsRemaining: MAX_SECURITY_CODE_REQUESTS - nextCount, + delivery: codeResult.delivery, + ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), + }); + } catch (err) { + return error(res, err.message, 500); + } +} + +async function verifyEmailChange(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + if (!user.pendingEmail) return error(res, 'Email almashtirish so‘rovi topilmadi.', 400); + + try { + await consumeAuthCode({ userId: user.id, type: AuthCodeType.EMAIL_CHANGE, code: req.body.code }); + } catch (err) { + return mapCodeError(res, err); + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + email: user.pendingEmail, + pendingEmail: null, + emailVerified: true, + emailVerifiedAt: new Date(), + emailChangeResendCount: 0, + emailChangeRequestedAt: null, + }, + }); + + return success(res, { + message: 'Email muvaffaqiyatli almashtirildi.', + ...createAuthPayload(updatedUser), + }); + } catch (err) { + if (err.code === 'P2002') return error(res, 'Bu Gmail boshqa akkauntda ishlatilgan.', 409); + return error(res, err.message, 500); + } +} + +async function requestAccountDeletion(req, res) { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) return error(res, 'Foydalanuvchi topilmadi.', 404); + + const passwordOk = await verifyCurrentPassword(user, req.body.password); + if (!passwordOk) { + return error(res, user.password ? 'Hisobni o‘chirish uchun parol noto‘g‘ri yoki kiritilmagan.' : 'Tasdiqlash amalga oshmadi.', 401, { + requiresPassword: Boolean(user.password), + }); + } + + if (user.accountDeleteResendCount >= MAX_SECURITY_CODE_REQUESTS) { + return securityRequestLimit(res, 'Kod 3 marta so‘raldi. Hisobni o‘chirish uchun adminga murojaat qiling.'); + } + + const codeResult = await issueAuthCode({ user, type: AuthCodeType.ACCOUNT_DELETE }); + const nextCount = user.accountDeleteResendCount + 1; + await prisma.user.update({ + where: { id: user.id }, + data: { + accountDeleteResendCount: nextCount, + accountDeleteRequestedAt: new Date(), + }, + }); + + return success(res, { + message: `Hisobni o‘chirish kodi ${user.email} manziliga yuborildi.`, + email: user.email, + attemptsRemaining: MAX_SECURITY_CODE_REQUESTS - nextCount, + delivery: codeResult.delivery, + ...(codeResult.devCode ? { devCode: codeResult.devCode } : {}), + }); + } catch (err) { + return error(res, err.message, 500); + } +} + async function deleteAccount(req, res) { try { - const { password } = req.body || {}; + const { code } = req.body || {}; const user = await prisma.user.findUnique({ where: { id: req.user.id } }); if (!user) { return error(res, 'Foydalanuvchi topilmadi.', 404); } - if (user.password) { - if (!password) { - return error(res, 'Hisobni o\'chirish uchun parolni kiriting.', 400, { - requiresPassword: true, - }); - } - - const validPassword = await bcrypt.compare(password, user.password); - if (!validPassword) { - return error(res, 'Parol noto\'g\'ri.', 401, { - requiresPassword: true, - }); - } + try { + await consumeAuthCode({ userId: user.id, type: AuthCodeType.ACCOUNT_DELETE, code }); + } catch (err) { + return mapCodeError(res, err); } await prisma.user.delete({ @@ -461,6 +651,20 @@ async function deleteAccount(req, res) { } } + +async function savePushToken(req, res) { + try { + const token = String((req.body && req.body.token) || '').trim(); + if (token.length < 20) { + return error(res, 'Yaroqsiz push token', 400); + } + await prisma.user.update({ where: { id: req.user.id }, data: { expoPushToken: token } }); + return success(res, { saved: true }); + } catch (err) { + return error(res, err.message, 500); + } +} + module.exports = { register, verifyEmail, @@ -469,9 +673,15 @@ module.exports = { forgotPassword, resetPassword, googleAuth, + adminLogin, + adminLoginVerify, updateProfile, getMe, getPreferences, updatePreferences, + requestEmailChange, + verifyEmailChange, + requestAccountDeletion, deleteAccount, + savePushToken, }; diff --git a/backend/src/controllers/bookings.controller.js b/backend/src/controllers/bookings.controller.js index ffd52cb..6f65557 100644 --- a/backend/src/controllers/bookings.controller.js +++ b/backend/src/controllers/bookings.controller.js @@ -1,6 +1,8 @@ const { prisma } = require('../config/database'); const { success, error } = require('../utils/response'); const { createBookingSchema } = require('../schemas/booking.schema'); +const { sendBookingLeadEmail } = require('../services/email.service'); +const { resolveTourImageUrl } = require('../utils/tourImage'); function formatBooking(booking) { if (!booking) return null; @@ -15,11 +17,16 @@ function formatBooking(booking) { travelDate: booking.travelDate, message: booking.message, status: booking.status, + responseDeadlineAt: booking.responseDeadlineAt, totalEstimate: booking.totalEstimate, currency: booking.currency, source: booking.source, agencyNote: booking.agencyNote, adminNote: booking.adminNote, + confirmedAt: booking.confirmedAt, + rejectedAt: booking.rejectedAt, + cancelledAt: booking.cancelledAt, + completedAt: booking.completedAt, createdAt: booking.createdAt, updatedAt: booking.updatedAt, tour: booking.tour @@ -31,7 +38,16 @@ function formatBooking(booking) { duration: booking.tour.duration, price: booking.tour.price, priceMin: booking.tour.priceMin, - imageUrl: booking.tour.imageUrl, + priceCurrency: booking.tour.priceCurrency, + priceBasis: booking.tour.priceBasis, + imageUrl: resolveTourImageUrl(booking.tour), + responseTimeMinutes: booking.tour.responseTimeMinutes ?? 45, + hotelName: booking.tour.hotelName, + hotelCategory: booking.tour.hotelCategory, + roomType: booking.tour.roomType, + mealPlan: booking.tour.mealPlan, + mealPlanLabel: booking.tour.mealPlanLabel, + availabilityStatus: booking.tour.availabilityStatus, } : null, agency: booking.agency @@ -41,7 +57,9 @@ function formatBooking(booking) { name: booking.agency.name, city: booking.agency.city, phone: booking.agency.phone, + telegram: booking.agency.telegram, website: booking.agency.website, + imageUrl: booking.agency.imageUrl, } : null, }; @@ -64,14 +82,23 @@ async function create(req, res) { if (!tour) return error(res, 'Tour topilmadi yoki hali public emas', 404); const userId = req.user?.id || null; + if (userId) { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); + if (user && input.customerEmail && user.email.toLowerCase() !== input.customerEmail.toLowerCase()) { + return error(res, 'Booking emaili akkauntingiz emailiga mos bo‘lishi kerak', 400); + } + } const totalEstimate = tour.priceMin ? tour.priceMin * input.travelers : null; + const responseDeadlineAt = new Date( + Date.now() + Math.max(5, Number(tour.responseTimeMinutes || 45)) * 60 * 1000 + ); const booking = await prisma.tourBooking.create({ data: { tourId: tour.id, agencyId: tour.agencyId, userId, customerName: input.customerName, - customerEmail: input.customerEmail.toLowerCase(), + customerEmail: input.customerEmail ? input.customerEmail.toLowerCase() : null, customerPhone: input.customerPhone || null, travelers: input.travelers, travelDate: input.travelDate ? new Date(input.travelDate) : null, @@ -80,10 +107,27 @@ async function create(req, res) { currency: 'USD', source: input.source || 'mobile', status: 'pending', + responseDeadlineAt, }, - include: { tour: true, agency: true }, + include: { tour: true, agency: { include: { ownerAccount: true } } }, }); + // Notify the agency about the new lead (free) — fire-and-forget + const agencyEmail = booking.agency?.ownerAccount?.email || null; + if (agencyEmail) { + sendBookingLeadEmail({ + to: agencyEmail, + agencyName: booking.agency.name, + tourTitle: booking.tour?.title, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + travelers: booking.travelers, + travelDate: booking.travelDate, + message: booking.message, + }).catch(() => {}); + } + return success(res, { booking: formatBooking(booking) }, 201); } catch (err) { return error(res, err.errors?.[0]?.message || err.message, 400); @@ -93,7 +137,23 @@ async function create(req, res) { async function listMine(req, res) { try { const email = String(req.query.email || '').trim().toLowerCase(); - const where = req.user?.id ? { userId: req.user.id } : email ? { customerEmail: email } : null; + let where = email ? { customerEmail: email } : null; + if (req.user?.id) { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { email: true }, + }); + if (!user) return error(res, 'Foydalanuvchi topilmadi', 404); + + await prisma.tourBooking.updateMany({ + where: { + userId: null, + customerEmail: user.email.toLowerCase(), + }, + data: { userId: req.user.id }, + }); + where = { userId: req.user.id }; + } if (!where) return error(res, 'Token yoki email talab qilinadi', 401); const items = await prisma.tourBooking.findMany({ diff --git a/backend/src/controllers/home.controller.js b/backend/src/controllers/home.controller.js index e14f2c7..44e6a5b 100644 --- a/backend/src/controllers/home.controller.js +++ b/backend/src/controllers/home.controller.js @@ -1,6 +1,7 @@ const { prisma } = require('../config/database'); const { logger } = require('../config/logger'); const { success, error } = require('../utils/response'); +const { resolveTourImageUrl } = require('../utils/tourImage'); const HOME_TYPES = ['landmark', 'restaurant', 'hotel', 'transport']; const LEGACY_PROVIDER_TERMS = ['google', 'mapbox', '2gis', 'manual_curated', 'fallback']; @@ -48,6 +49,36 @@ function normalizeBadge(value) { return 'all'; } +function publicAgencyWhere() { + return { active: true, approvalStatus: 'approved' }; +} + +function publicTourWhere({ agencyOnly = false } = {}) { + const agencyWhere = publicAgencyWhere(); + return { + active: true, + approvalStatus: 'approved', + AND: [ + agencyOnly + ? { agency: { is: agencyWhere } } + : { + OR: [ + { agencyId: null }, + { agency: { is: agencyWhere } }, + ], + }, + ], + }; +} + +function tourOrderBy(badge) { + if (badge === 'Popular') { + return [{ rating: 'desc' }, { approvedAt: 'desc' }, { updatedAt: 'desc' }, { createdAt: 'desc' }]; + } + + return [{ approvedAt: 'desc' }, { updatedAt: 'desc' }, { createdAt: 'desc' }, { rating: 'desc' }]; +} + function normalizeEntityType(value) { const raw = String(value || '').toLowerCase().trim(); if (raw === 'poi' || raw === 'destination') return 'place'; @@ -150,13 +181,41 @@ function formatTour(item) { subtitle: item.subtitle, description: item.description || '', duration: item.duration, + responseTimeMinutes: item.responseTimeMinutes ?? 45, price: item.price || '', priceMin: item.priceMin ?? null, + priceCurrency: item.priceCurrency || null, + priceBasis: item.priceBasis || null, rating: item.rating ?? 0, badge: item.badge || 'Latest', - imageUrl: item.imageUrl || null, + imageUrl: resolveTourImageUrl(item), highlights: Array.isArray(item.highlights) ? item.highlights : [], itinerary: item.itinerary || null, + departureCity: item.departureCity || null, + destinationCountry: item.destinationCountry || null, + tourGroup: item.tourGroup || null, + nights: item.nights ?? null, + days: item.days ?? null, + hotelIncluded: Boolean(item.hotelIncluded), + flightIncluded: Boolean(item.flightIncluded), + discount: item.discount || null, + priceBasisPeople: item.priceBasisPeople ?? null, + priceLockMinutes: item.priceLockMinutes ?? null, + priceLockUntil: item.priceLockUntil || null, + hotelName: item.hotelName || null, + hotelCategory: item.hotelCategory || null, + hotelLocation: item.hotelLocation || null, + roomType: item.roomType || null, + mealPlan: item.mealPlan || null, + mealPlanLabel: item.mealPlanLabel || null, + childPolicy: item.childPolicy || null, + flightSeatStatus: item.flightSeatStatus || null, + availabilityStatus: item.availabilityStatus || null, + instantConfirmation: Boolean(item.instantConfirmation), + stopSale: Boolean(item.stopSale), + promo: Boolean(item.promo), + priceIncludes: Array.isArray(item.priceIncludes) ? item.priceIncludes : [], + priceExcludes: Array.isArray(item.priceExcludes) ? item.priceExcludes : [], agency: item.agency ? { id: item.agency.id, @@ -164,6 +223,10 @@ function formatTour(item) { name: item.agency.name, city: item.agency.city, rating: item.agency.rating, + phone: item.agency.phone, + telegram: item.agency.telegram || null, + website: item.agency.website, + imageUrl: item.agency.imageUrl, } : null, source: item.source || 'admin', @@ -185,6 +248,7 @@ function formatAgency(item) { reviews: item.reviews ?? 0, tours: item.toursCount ?? item._count?.tours ?? 0, phone: item.phone || null, + telegram: item.telegram || null, website: item.website || null, imageUrl: item.imageUrl || null, source: item.source || 'admin', @@ -332,20 +396,7 @@ async function resolveTours({ agencyOnly = false, paginated = false, }) { - const where = { - active: true, - approvalStatus: 'approved', - AND: [ - agencyOnly - ? { agency: { is: { active: true, approvalStatus: 'approved' } } } - : { - OR: [ - { agencyId: null }, - { agency: { is: { active: true, approvalStatus: 'approved' } } }, - ], - }, - ], - }; + const where = publicTourWhere({ agencyOnly }); if (badge !== 'all') where.badge = badge; const search = String(q || '').trim(); if (search) { @@ -359,11 +410,10 @@ async function resolveTours({ }); } - const orderBy = badge === 'Latest' ? [{ createdAt: 'desc' }, { rating: 'desc' }] : [{ rating: 'desc' }, { createdAt: 'desc' }]; const query = { where, include: { agency: true }, - orderBy, + orderBy: tourOrderBy(badge), take: limit, }; @@ -395,7 +445,7 @@ async function resolveAgencies({ sort = 'top', limit = 8 }) { ? [{ featured: 'desc' }, { landingSortOrder: 'asc' }, { toursCount: 'desc' }, { rating: 'desc' }] : [{ featured: 'desc' }, { landingSortOrder: 'asc' }, { rating: 'desc' }, { reviews: 'desc' }]; const items = await prisma.tourAgency.findMany({ - where: { active: true, approvalStatus: 'approved' }, + where: publicAgencyWhere(), include: { _count: { select: { tours: true } } }, orderBy, take: Math.max(limit * 4, 40), diff --git a/backend/src/middleware/adminAuth.middleware.js b/backend/src/middleware/adminAuth.middleware.js index 86c5bcf..6f82140 100644 --- a/backend/src/middleware/adminAuth.middleware.js +++ b/backend/src/middleware/adminAuth.middleware.js @@ -1,12 +1,27 @@ const { error } = require('../utils/response'); +const { verifyToken } = require('../utils/jwt'); +// Admin marshrutlari ikki yo'l bilan himoyalangan: +// 1) x-admin-key (eski website admin-proxy secret) — orqaga moslik uchun. +// 2) Bearer JWT (role=admin) — yangi premium admin panel. function adminAuthMiddleware(req, res, next) { const key = req.headers['x-admin-key']; const expectedKey = process.env.ADMIN_SECRET_KEY || (process.env.NODE_ENV !== 'production' ? 'change_me' : ''); - if (!key || !expectedKey || key !== expectedKey) { - return error(res, 'Forbidden', 403); + if (key && expectedKey && key === expectedKey) { + return next(); } - next(); + + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (token) { + const decoded = verifyToken(token); + if (decoded && decoded.role === 'admin') { + req.adminUser = { id: decoded.id, email: decoded.email, username: decoded.username, role: 'admin' }; + return next(); + } + } + + return error(res, 'Forbidden', 403); } module.exports = { adminAuthMiddleware }; diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index 3cc2533..f156043 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -4,6 +4,7 @@ const admin = require('../controllers/admin.controller'); router.use(adminAuthMiddleware); +router.get('/me', admin.adminMe); router.get('/stats', admin.getStats); router.get('/users', admin.getUsers); @@ -58,5 +59,13 @@ router.delete('/transport/routes/:id', admin.deleteTransportRoute); router.get('/feedback', admin.getFeedback); router.delete('/feedback/:id', admin.deleteFeedback); +router.patch('/feedback/:id/status', admin.updateFeedbackStatus); + +// Premium admin panel qo'shimcha endpointlari +router.get('/reports', admin.getReports); +router.get('/reviews', admin.getReviews); +router.delete('/reviews/:id', admin.deleteReview); +router.delete('/tours/:id', admin.deleteAdminTour); +router.post('/business', admin.createPartner); module.exports = router; diff --git a/backend/src/routes/agency.routes.js b/backend/src/routes/agency.routes.js index 02b2283..e92aa70 100644 --- a/backend/src/routes/agency.routes.js +++ b/backend/src/routes/agency.routes.js @@ -5,10 +5,14 @@ const { agencyAuthMiddleware } = require('../middleware/agencyAuth.middleware'); router.post('/auth/register', agency.register); router.post('/auth/verify-email', agency.verifyEmail); router.post('/auth/login', agency.login); +router.post('/auth/google', agency.googleAuth); router.use(agencyAuthMiddleware); router.get('/auth/me', agency.me); +router.post('/auth/email-change/request', agency.requestEmailChange); +router.post('/auth/email-change/resend', agency.resendEmailChange); +router.post('/auth/email-change/confirm', agency.confirmEmailChange); router.get('/application', agency.getApplication); router.put('/application', agency.upsertApplication); router.post('/application/submit', agency.submitApplication); diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index bfaf2b6..4bfcf6e 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -7,11 +7,17 @@ const { forgotPassword, resetPassword, googleAuth, + adminLogin, + adminLoginVerify, updateProfile, getMe, getPreferences, updatePreferences, + requestEmailChange, + verifyEmailChange, + requestAccountDeletion, deleteAccount, + savePushToken, } = require('../controllers/auth.controller'); const { authMiddleware } = require('../middleware/auth.middleware'); const { validate } = require('../middleware/validate.middleware'); @@ -26,6 +32,9 @@ const { profileSchema, preferencesSchema, deleteAccountSchema, + requestEmailChangeSchema, + verifyEmailChangeSchema, + requestAccountDeletionSchema, } = require('../schemas/auth.schema'); router.post('/register', validate(registerSchema), register); @@ -35,10 +44,16 @@ router.post('/login', validate(loginSchema), login); router.post('/forgot-password', validate(forgotPasswordSchema), forgotPassword); router.post('/reset-password', validate(resetPasswordSchema), resetPassword); router.post('/google', validate(googleAuthSchema), googleAuth); +router.post('/admin/login', adminLogin); +router.post('/admin/login/verify', adminLoginVerify); router.get('/me', authMiddleware, getMe); router.put('/profile', authMiddleware, validate(profileSchema), updateProfile); router.get('/preferences', authMiddleware, getPreferences); router.put('/preferences', authMiddleware, validate(preferencesSchema), updatePreferences); +router.post('/email-change/request', authMiddleware, validate(requestEmailChangeSchema), requestEmailChange); +router.post('/email-change/verify', authMiddleware, validate(verifyEmailChangeSchema), verifyEmailChange); +router.post('/account-deletion/request', authMiddleware, validate(requestAccountDeletionSchema), requestAccountDeletion); +router.post('/push-token', authMiddleware, savePushToken); router.delete('/account', authMiddleware, validate(deleteAccountSchema), deleteAccount); module.exports = router; diff --git a/backend/src/schemas/agency.schema.js b/backend/src/schemas/agency.schema.js index 4ffe082..84d6f2b 100644 --- a/backend/src/schemas/agency.schema.js +++ b/backend/src/schemas/agency.schema.js @@ -8,6 +8,69 @@ const nullableUrl = z .or(z.literal('')) .transform((value) => value || undefined); +const imageValue = z + .string() + .trim() + .max(12_000_000) + .refine( + (value) => + !value || + /^https?:\/\//i.test(value) || + /^\/uploads\//i.test(value) || + /^data:image\/(?:jpeg|png|webp|gif);base64,/i.test(value), + 'Rasm URL yoki JPG/PNG/WEBP/GIF fayl bo‘lishi kerak' + ) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + +const tourBadge = z + .string() + .trim() + .optional() + .or(z.literal('')) + .transform((value) => (String(value || '').toLowerCase() === 'popular' ? 'Popular' : 'Latest')); + +const optionalText = z + .string() + .trim() + .max(240) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + +const optionalLongText = z + .string() + .trim() + .max(1000) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + +const stringList = z + .array(z.string().trim().min(1).max(160)) + .max(30) + .optional() + .default([]); + +const mealPlan = z + .enum(['RO', 'BB', 'HB', 'FB', 'AI', 'UAI', 'UALL', 'FBT']) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + +const availabilityStatus = z + .enum(['available', 'few_seats', 'on_request', 'sold_out']) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + +const flightSeatStatus = z + .enum(['available', 'few_seats', 'on_request', 'no_seats', 'not_included']) + .optional() + .or(z.literal('')) + .transform((value) => value || undefined); + const registerSchema = z.object({ email: z.string().trim().email(), password: z.string().min(8), @@ -18,6 +81,14 @@ const verifyEmailSchema = z.object({ code: z.string().trim().min(4).max(10), }); +const emailChangeRequestSchema = z.object({ + newEmail: z.string().trim().email(), +}); + +const emailChangeConfirmSchema = z.object({ + code: z.string().trim().length(6), +}); + const loginSchema = z.object({ email: z.string().trim().email(), password: z.string().min(1), @@ -36,6 +107,7 @@ const applicationSchema = z.object({ instagram: z.string().trim().optional().or(z.literal('')), serviceTypes: z.array(z.string().trim().min(2)).min(1).max(12), description: z.string().trim().min(20), + imageUrl: imageValue, documents: z.any().optional(), }); @@ -45,12 +117,43 @@ const tourSchema = z.object({ subtitle: z.string().trim().min(3), description: z.string().trim().optional().or(z.literal('')), duration: z.string().trim().min(2), + responseTimeMinutes: z.coerce.number().int().min(5).max(1440).default(45), price: z.string().trim().optional().or(z.literal('')), priceMin: z.coerce.number().int().nonnegative().optional().nullable(), - badge: z.string().trim().optional().default('Latest'), - imageUrl: nullableUrl, + priceCurrency: optionalText, + priceBasis: optionalText, + badge: tourBadge, + imageUrl: imageValue, itinerary: z.any().optional(), highlights: z.array(z.string().trim().min(2)).max(20).optional().default([]), + departureCity: optionalText, + destinationCountry: optionalText, + tourGroup: optionalText, + nights: z.coerce.number().int().nonnegative().optional().nullable(), + days: z.coerce.number().int().nonnegative().optional().nullable(), + hotelIncluded: z.coerce.boolean().optional().default(false), + flightIncluded: z.coerce.boolean().optional().default(false), + discount: optionalText, + priceBasisPeople: z.coerce.number().int().positive().optional().nullable(), + priceLockMinutes: z.coerce.number().int().nonnegative().max(100000).optional().nullable(), + hotelName: optionalText, + hotelCategory: optionalText, + hotelLocation: optionalText, + roomType: optionalText, + mealPlan, + mealPlanLabel: optionalText, + childPolicy: optionalLongText, + flightSeatStatus, + availabilityStatus, + instantConfirmation: z.coerce.boolean().optional().default(false), + stopSale: z.coerce.boolean().optional().default(false), + promo: z.coerce.boolean().optional().default(false), + priceIncludes: stringList, + priceExcludes: stringList, +}); + +const googleAuthSchema = z.object({ + idToken: z.string().trim().min(10, 'Google idToken talab qilinadi'), }); const adminReviewSchema = z.object({ @@ -58,8 +161,11 @@ const adminReviewSchema = z.object({ }); module.exports = { + googleAuthSchema, registerSchema, verifyEmailSchema, + emailChangeRequestSchema, + emailChangeConfirmSchema, loginSchema, applicationSchema, tourSchema, diff --git a/backend/src/schemas/auth.schema.js b/backend/src/schemas/auth.schema.js index b891b8c..5fd26be 100644 --- a/backend/src/schemas/auth.schema.js +++ b/backend/src/schemas/auth.schema.js @@ -59,6 +59,19 @@ const preferencesSchema = z.object({ const deleteAccountSchema = z.object({ confirm: z.literal(true), + code: codeSchema, +}); + +const requestEmailChangeSchema = z.object({ + newEmail: emailSchema, + password: z.string().min(1, 'Parol talab qilinadi').optional(), +}); + +const verifyEmailChangeSchema = z.object({ + code: codeSchema, +}); + +const requestAccountDeletionSchema = z.object({ password: z.string().min(1, 'Parol talab qilinadi').optional(), }); @@ -73,4 +86,7 @@ module.exports = { profileSchema, preferencesSchema, deleteAccountSchema, + requestEmailChangeSchema, + verifyEmailChangeSchema, + requestAccountDeletionSchema, }; diff --git a/backend/src/schemas/booking.schema.js b/backend/src/schemas/booking.schema.js index 7240531..b49e232 100644 --- a/backend/src/schemas/booking.schema.js +++ b/backend/src/schemas/booking.schema.js @@ -5,7 +5,7 @@ const createBookingSchema = z tourId: z.string().trim().optional(), tourSlug: z.string().trim().optional(), customerName: z.string().trim().min(2, 'Ism kamida 2 ta belgidan iborat bo‘lsin'), - customerEmail: z.string().trim().email('Email noto‘g‘ri'), + customerEmail: z.string().trim().email('Email noto‘g‘ri').optional().or(z.literal('')), customerPhone: z.string().trim().min(5).max(40).optional().or(z.literal('')), travelers: z.coerce.number().int().min(1).max(50).default(1), travelDate: z.string().trim().optional().or(z.literal('')), @@ -15,6 +15,10 @@ const createBookingSchema = z .refine((value) => value.tourId || value.tourSlug, { message: 'tourId yoki tourSlug talab qilinadi', path: ['tourId'], + }) + .refine((value) => Boolean((value.customerEmail || '').trim()) || Boolean((value.customerPhone || '').trim()), { + message: 'Email yoki telefon raqamidan kamida bittasi kerak', + path: ['customerPhone'], }); const bookingStatusSchema = z.object({ diff --git a/backend/src/services/aiItinerary.js b/backend/src/services/aiItinerary.js new file mode 100644 index 0000000..e4180c7 --- /dev/null +++ b/backend/src/services/aiItinerary.js @@ -0,0 +1,206 @@ +// TravelorAI — AI marshrut generatori uchun umumiy mantiq (provider-agnostik). +// Claude (Anthropic) va Gemini (Google) ikkalasi ham shu prompt/schema/mapper'dan foydalanadi. +// POI/destination ma'lumoti bo'lmagan (asosan outbound) shaharlar uchun noldan kun-marshrut. + +const ALLOWED_TYPES = new Set(['transport', 'landmark', 'food', 'attraction', 'hotel']); +const ACTIVITY_ICONS = { transport: 'TR', landmark: 'LM', food: 'FD', attraction: 'AT', hotel: 'HT' }; + +// enum ISHLATILMAYDI (Gemini responseJsonSchema cheklovi) — type mapping bosqichida tekshiriladi. +const AI_ITINERARY_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + title: { type: 'string' }, + destinations: { type: 'array', items: { type: 'string' } }, + days: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + day: { type: 'integer' }, + city: { type: 'string' }, + hotel: { type: 'string' }, + hotelCost: { type: 'integer' }, + activities: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + time: { type: 'string' }, + type: { type: 'string' }, + name: { type: 'string' }, + note: { type: 'string' }, + cost: { type: 'integer' }, + }, + required: ['time', 'type', 'name', 'note', 'cost'], + }, + }, + }, + required: ['day', 'city', 'hotel', 'hotelCost', 'activities'], + }, + }, + highlights: { type: 'array', items: { type: 'string' } }, + tips: { type: 'array', items: { type: 'string' } }, + warnings: { type: 'array', items: { type: 'string' } }, + }, + required: ['title', 'destinations', 'days', 'highlights', 'tips', 'warnings'], +}; + +function buildAiItineraryPrompt(input) { + const city = String(input.city || input.departureCity || '').trim(); + const duration = Math.max(1, Number(input.duration || 1)); + const travelers = Math.max(1, Number(input.travelers || 1)); + const budget = Number(input.budget || 0); + const interests = Array.isArray(input.interests) ? input.interests : []; + + return [ + 'You are TravelorAI, an expert travel planner for travelers from Uzbekistan.', + `Build a realistic, day-by-day itinerary for a trip to "${city}".`, + '', + 'Trip parameters:', + `- Destination: ${city}`, + `- Departure city: ${input.departureCity || '-'}`, + `- Duration: ${duration} days`, + `- Travelers: ${travelers} (companions: ${input.companions || 'solo'})`, + `- Budget (total, all travelers): ${budget > 0 ? budget + ' UZS' : 'flexible'}`, + `- Travel style: ${input.style || 'mid'} (budget=economy, mid=comfort, luxury=premium)`, + `- Interests: ${interests.length ? interests.join(', ') : 'general sightseeing'}`, + `- Food preference: ${input.foodPreferences || 'none'} (respect halal if requested)`, + `- Transport preference: ${input.transportType || 'cheap'}`, + '', + 'Rules:', + '- Write ALL text in Uzbek (latin script). No markdown.', + '- Use REAL, well-known place names for the destination (actual landmarks, museums, districts, restaurants, hotels). Do not invent fake names.', + '- Each day: 4-6 activities ordered by time (HH:MM). Include arrival/transfer on day 1 and departure on the last day.', + '- activity.type must be one of: transport, landmark, food, attraction, hotel.', + '- Provide cost for EACH activity and hotelCost per night as integer UZS for the WHOLE GROUP of ' + travelers + ' traveler(s). Use ~13000 UZS = 1 USD for conversions.', + budget > 0 + ? '- Keep the estimated total cost within the budget when realistic; if the budget is too low for the destination, still produce a sensible plan and add a short warning.' + : '- Estimate sensible market costs for the destination.', + '- hotel: a real hotel/area name suitable for the style; hotelCost: per-night group cost (0 only if no overnight, e.g. last day).', + '- highlights: 3-5 short trip highlights. tips: 4-6 practical tips (money, transport, culture, safety). warnings: only real concerns (visa, budget, season) or empty.', + '- Return ONLY JSON matching the provided schema.', + ].join('\n'); +} + +function genId() { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +function coerceActivity(raw) { + const type = ALLOWED_TYPES.has(String(raw?.type || '').toLowerCase()) + ? String(raw.type).toLowerCase() + : 'attraction'; + return { + time: String(raw?.time || '').trim() || '09:00', + type, + name: String(raw?.name || '').trim(), + note: String(raw?.note || '').trim(), + cost: Math.max(0, Math.round(Number(raw?.cost || 0))), + icon: ACTIVITY_ICONS[type] || 'AT', + }; +} + +function tryParseJson(text) { + if (!text || typeof text !== 'string') return null; + try { + return JSON.parse(text); + } catch { + const first = text.indexOf('{'); + const last = text.lastIndexOf('}'); + if (first >= 0 && last > first) { + try { + return JSON.parse(text.slice(first, last + 1)); + } catch { + return null; + } + } + return null; + } +} + +function mapAiItineraryToPlan(parsed, input, meta = {}) { + const duration = Math.max(1, Number(input.duration || 1)); + const travelers = Math.max(1, Number(input.travelers || 1)); + const budget = Number(input.budget || 0); + const style = input.style || 'mid'; + + const rawDays = Array.isArray(parsed?.days) ? parsed.days : []; + if (!rawDays.length) return null; + + let transport = 0; + let accommodation = 0; + let food = 0; + let attractions = 0; + + const days = rawDays.map((rawDay, index) => { + const activities = (Array.isArray(rawDay?.activities) ? rawDay.activities : []) + .map((a) => coerceActivity(a)) + .filter((a) => a.name); + + activities.forEach((a) => { + if (a.type === 'transport') transport += a.cost; + else if (a.type === 'food') food += a.cost; + else attractions += a.cost; + }); + + const hotelCost = Math.max(0, Math.round(Number(rawDay?.hotelCost || 0))); + accommodation += hotelCost; + const destination = String(rawDay?.city || input.city || '').trim(); + + return { + day: Number(rawDay?.day || index + 1), + dayNumber: Number(rawDay?.day || index + 1), + destination, + city: destination, + activities, + hotel: String(rawDay?.hotel || '').trim(), + hotelCost, + accommodation: { name: String(rawDay?.hotel || '').trim(), cost: hotelCost, type: 'hotel' }, + }; + }); + + const miscBudget = budget > 0 ? Math.round(budget * 0.05) : 0; + const totalCost = transport + accommodation + food + attractions + miscBudget; + const perPersonCost = Math.round(totalCost / travelers); + + const destinations = + Array.isArray(parsed?.destinations) && parsed.destinations.length + ? parsed.destinations.map((d) => String(d).trim()).filter(Boolean) + : [String(input.city || '').trim()].filter(Boolean); + + const asList = (value, max) => + Array.from(new Set((Array.isArray(value) ? value : []).map((x) => String(x || '').trim()).filter(Boolean))).slice( + 0, + max + ); + + return { + id: genId(), + title: String(parsed?.title || '').trim() || `${input.city} sayohati`, + totalCost, + perPersonCost, + budgetUsed: budget > 0 ? Math.round((totalCost / budget) * 100) : 0, + budgetRemaining: budget > 0 ? budget - totalCost : 0, + style, + travelers, + duration, + destinations, + transportLegs: [], + days, + breakdown: { transport, accommodation, food, attractions, misc: miscBudget }, + tips: asList(parsed?.tips, 10), + warnings: asList(parsed?.warnings, 8), + highlights: asList(parsed?.highlights, 6), + dataConfidence: 'ai_generated', + sourceSummary: `AI (${meta.provider || 'ai'}) tomonidan yaratilgan marshrut`, + alternatives: { transport: [] }, + verificationWarnings: [], + aiProvider: meta.provider || 'ai', + aiModel: meta.model || '', + }; +} + +module.exports = { AI_ITINERARY_SCHEMA, buildAiItineraryPrompt, mapAiItineraryToPlan, tryParseJson }; diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js index 854a23b..dc3f7b0 100644 --- a/backend/src/services/auth.service.js +++ b/backend/src/services/auth.service.js @@ -1,11 +1,19 @@ const crypto = require('crypto'); const axios = require('axios'); const { prisma } = require('../config/database'); -const { sendPasswordResetCodeEmail, sendVerificationCodeEmail } = require('./email.service'); +const { logger } = require('../config/logger'); +const { + sendAccountDeleteCodeEmail, + sendEmailChangeCodeEmail, + sendPasswordResetCodeEmail, + sendVerificationCodeEmail, +} = require('./email.service'); const AuthCodeType = { EMAIL_VERIFICATION: 'EMAIL_VERIFICATION', PASSWORD_RESET: 'PASSWORD_RESET', + EMAIL_CHANGE: 'EMAIL_CHANGE', + ACCOUNT_DELETE: 'ACCOUNT_DELETE', }; const AuthProvider = { @@ -33,6 +41,7 @@ function buildPublicUser(user) { email: user.email, emailVerified: user.emailVerified, authProvider: user.authProvider === AuthProvider.GOOGLE ? 'google' : 'local', + role: user.role || 'traveler', }; } @@ -44,7 +53,7 @@ function hashCode(code) { return crypto.createHash('sha256').update(code).digest('hex'); } -async function issueAuthCode({ user, type }) { +async function issueAuthCode({ user, type, newEmail }) { const code = generateNumericCode(); const codeHash = hashCode(code); const ttlMinutes = type === AuthCodeType.EMAIL_VERIFICATION ? EMAIL_VERIFICATION_TTL_MINUTES : PASSWORD_RESET_TTL_MINUTES; @@ -63,24 +72,39 @@ async function issueAuthCode({ user, type }) { }, }); - const emailResult = - type === AuthCodeType.EMAIL_VERIFICATION - ? await sendVerificationCodeEmail({ - email: user.email, - name: user.name, - code, - expiresInMinutes: ttlMinutes, - }) - : await sendPasswordResetCodeEmail({ - email: user.email, - name: user.name, - code, - expiresInMinutes: ttlMinutes, - }); + const mailPayload = { + email: user.email, + name: user.name, + code, + expiresInMinutes: ttlMinutes, + }; + + let sendFn; + if (type === AuthCodeType.EMAIL_VERIFICATION) { + sendFn = () => sendVerificationCodeEmail(mailPayload); + } else if (type === AuthCodeType.EMAIL_CHANGE) { + sendFn = () => sendEmailChangeCodeEmail({ ...mailPayload, newEmail }); + } else if (type === AuthCodeType.ACCOUNT_DELETE) { + sendFn = () => sendAccountDeleteCodeEmail(mailPayload); + } else { + sendFn = () => sendPasswordResetCodeEmail(mailPayload); + } + // Emailni BLOKLAMASDAN (fire-and-forget) yuboramiz: Gmail SMTP 2-13s olishi mumkin, + // shuning uchun javobni kutdirib qo'ymaymiz. Kod allaqachon bazaga yozilgan — + // foydalanuvchi email kelganda kiritadi. Xato bo'lsa logga yozamiz. + Promise.resolve() + .then(sendFn) + .catch((err) => + logger.error('Auth email send failed (async)', { type, email: user.email, message: err.message }) + ); + + // SMTP sozlangan bo'lsa 'smtp', aks holda 'log' (dev'da devCode qaytaramiz). + const willSendEmail = Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT); return { - ...emailResult, + delivery: willSendEmail ? 'smtp' : 'log', expiresInMinutes: ttlMinutes, + ...(process.env.NODE_ENV !== 'production' && !willSendEmail ? { devCode: code } : {}), }; } diff --git a/backend/src/services/claudePlanner.service.js b/backend/src/services/claudePlanner.service.js new file mode 100644 index 0000000..21c9f53 --- /dev/null +++ b/backend/src/services/claudePlanner.service.js @@ -0,0 +1,71 @@ +const { logger } = require('../config/logger'); +const { AI_ITINERARY_SCHEMA, buildAiItineraryPrompt, mapAiItineraryToPlan, tryParseJson } = require('./aiItinerary'); + +// TravelorAI — Anthropic Claude orqali to'liq AI marshrut generatori. +// ANTHROPIC_API_KEY bo'lmasa null qaytaradi (xato bermaydi). + +const DEFAULT_MODEL = 'claude-opus-4-8'; + +function readConfig() { + return { + apiKey: String(process.env.ANTHROPIC_API_KEY || '').trim(), + model: String(process.env.ANTHROPIC_MODEL || DEFAULT_MODEL).trim() || DEFAULT_MODEL, + enabled: String(process.env.CLAUDE_PLANNER_ENABLED || 'true').toLowerCase() !== 'false', + timeoutMs: Math.max(8000, Number(process.env.ANTHROPIC_TIMEOUT_MS || 45000)), + }; +} + +let client = null; +function getClient(config) { + if (!config.apiKey) return null; + if (!client) { + let Anthropic; + try { + Anthropic = require('@anthropic-ai/sdk'); + } catch { + logger.warn('Claude planner: @anthropic-ai/sdk not installed'); + return null; + } + client = new Anthropic({ apiKey: config.apiKey, timeout: config.timeoutMs, maxRetries: 1 }); + } + return client; +} + +function extractText(message) { + const blocks = Array.isArray(message?.content) ? message.content : []; + return blocks + .filter((b) => b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') + .trim(); +} + +async function generateTripPlanWithClaude(input) { + const config = readConfig(); + if (!config.enabled) return null; + const anthropic = getClient(config); + if (!anthropic) return null; + + try { + const message = await anthropic.messages.create({ + model: config.model, + max_tokens: 16000, + thinking: { type: 'disabled' }, + output_config: { format: { type: 'json_schema', schema: AI_ITINERARY_SCHEMA } }, + messages: [{ role: 'user', content: buildAiItineraryPrompt(input) }], + }); + + const parsed = tryParseJson(extractText(message)); + const plan = mapAiItineraryToPlan(parsed, input, { provider: 'anthropic', model: config.model }); + if (!plan) { + logger.warn('Claude planner returned unusable payload'); + return null; + } + return plan; + } catch (err) { + logger.warn('Claude planner generation failed', { status: err?.status, message: err?.message }); + return null; + } +} + +module.exports = { generateTripPlanWithClaude }; diff --git a/backend/src/services/email.service.js b/backend/src/services/email.service.js index d330db3..4daa693 100644 --- a/backend/src/services/email.service.js +++ b/backend/src/services/email.service.js @@ -22,6 +22,14 @@ function getTransporter() { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: String(process.env.SMTP_SECURE || 'false') === 'true', + // Hang'lardan himoya: SMTP sekin bo'lsa cheksiz kutib qolmasin. + connectionTimeout: Number(process.env.SMTP_CONNECTION_TIMEOUT || 10000), + greetingTimeout: Number(process.env.SMTP_GREETING_TIMEOUT || 10000), + socketTimeout: Number(process.env.SMTP_SOCKET_TIMEOUT || 20000), + // Ulanishni qayta ishlatish — har email uchun yangi TLS handshake (sekin) qilmaslik. + pool: true, + maxConnections: Number(process.env.SMTP_MAX_CONNECTIONS || 3), + maxMessages: Number(process.env.SMTP_MAX_MESSAGES || 50), ...(SMTP_ALLOW_INVALID_TLS ? { tls: { rejectUnauthorized: false } } : {}), auth: process.env.SMTP_USER ? { @@ -117,6 +125,50 @@ async function sendVerificationCodeEmail({ email, name, code, expiresInMinutes } }); } +async function sendEmailChangeCodeEmail({ email, name, code, expiresInMinutes, newEmail }) { + return sendMail({ + to: email, + subject: `${APP_NAME} email almashtirish kodi`, + html: buildHtml({ + heading: 'Email manzilini almashtirish', + intro: `${name || 'Salom'}, akkauntingiz emailini ${safeText(newEmail)} manziliga almashtirish uchun quyidagi kodni kiriting.`, + code, + expiresInMinutes, + footer: "Agar bu so'rovni siz yubormagan bo'lsangiz, kodni hech kimga bermang va support bilan bog'laning.", + }), + text: `Email almashtirish kodi: ${code}. Yangi email: ${safeText(newEmail)}. Kod ${expiresInMinutes} daqiqa amal qiladi.`, + logMeta: { type: 'agency_email_change', code, email, newEmail: safeText(newEmail) }, + }); +} + +// Email almashtirish YAKUNLANGANDA xabarnoma — eski va yangi manzilga (kodsiz) +async function sendEmailChangedNoticeEmail({ oldEmail, newEmail }) { + const html = ` +
+
+

${APP_NAME}

+

Email muvaffaqiyatli o'zgartirildi ✅

+

+ Agency akkauntingiz login emaili ${safeText(oldEmail)} dan ${safeText(newEmail)} ga almashtirildi. + Endi tizimga yangi email bilan kirasiz. +

+

+ Eslatma: Google orqali kirish eski hisobdan uzildi — Google bilan kirish uchun endi yangi emailingizdagi Google akkauntdan foydalaning. +

+

+ Agar bu o'zgarishni siz qilmagan bo'lsangiz, darhol support bilan bog'laning: ${SUPPORT_EMAIL} +

+
+
+ `; + const text = `Agency login emailingiz ${safeText(oldEmail)} dan ${safeText(newEmail)} ga almashtirildi. Bu siz bo'lmasangiz: ${SUPPORT_EMAIL}`; + const results = await Promise.allSettled([ + sendMail({ to: newEmail, subject: `${APP_NAME} — email o'zgartirildi`, html, text, logMeta: { type: 'agency_email_changed_notice', email: newEmail } }), + sendMail({ to: oldEmail, subject: `${APP_NAME} — email o'zgartirildi`, html, text, logMeta: { type: 'agency_email_changed_notice', email: oldEmail } }), + ]); + return results; +} + async function sendPasswordResetCodeEmail({ email, name, code, expiresInMinutes }) { return sendMail({ to: email, @@ -133,6 +185,22 @@ async function sendPasswordResetCodeEmail({ email, name, code, expiresInMinutes }); } +async function sendAccountDeleteCodeEmail({ email, name, code, expiresInMinutes }) { + return sendMail({ + to: email, + subject: `${APP_NAME} hisobni o'chirish kodi`, + html: buildHtml({ + heading: "Hisobni o'chirishni tasdiqlang", + intro: `${name || 'Salom'}, akkauntingizni butunlay o'chirish uchun quyidagi kodni kiriting.`, + code, + expiresInMinutes, + footer: "Agar bu so'rovni siz yubormagan bo'lsangiz, kodni hech kimga bermang va support bilan bog'laning.", + }), + text: `Hisobni o'chirish kodi: ${code}. Kod ${expiresInMinutes} daqiqa amal qiladi.`, + logMeta: { type: 'account_delete', code, email }, + }); +} + function safeText(value) { if (typeof value !== 'string') return ''; return value.replace(/[<>]/g, '').trim(); @@ -211,8 +279,51 @@ async function sendSupportFeedbackEmail({ }); } +async function sendBookingLeadEmail({ to, agencyName, tourTitle, customerName, customerPhone, customerEmail, travelers, travelDate, message }) { + if (!to) return { delivery: 'skipped' }; + const t = (v) => safeText(String(v ?? '')).trim() || '-'; + const subject = `[${APP_NAME}] Yangi so'rov: ${t(tourTitle)}`; + const html = ` +
+
+

${APP_NAME}

+

Yangi sayohat so'rovi

+

${t(agencyName)} — ${t(tourTitle)} turi bo'yicha yangi so'rov keldi.

+
+
    +
  • Mijoz: ${t(customerName)}
  • +
  • Telefon: ${t(customerPhone)}
  • +
  • Email: ${t(customerEmail)}
  • +
  • Kishi soni: ${t(travelers)}
  • +
  • Sayohat sanasi: ${t(travelDate)}
  • +
+ ${message ? `

"${safeText(message)}"

` : ''} +
+

Mijoz bilan telefon yoki Telegram orqali bog'laning. Portal: agency.travelorai.com

+
+
+ `; + const text = [ + `${APP_NAME} — yangi so'rov`, + `Tur: ${t(tourTitle)} (${t(agencyName)})`, + '', + `Mijoz: ${t(customerName)}`, + `Telefon: ${t(customerPhone)}`, + `Email: ${t(customerEmail)}`, + `Kishi: ${t(travelers)}`, + `Sana: ${t(travelDate)}`, + message ? `\nXabar: ${safeText(message)}` : '', + ].join('\n'); + + return sendMail({ to, subject, html, text, logMeta: { type: 'booking_lead', to } }); +} + module.exports = { sendVerificationCodeEmail, + sendEmailChangeCodeEmail, + sendEmailChangedNoticeEmail, sendPasswordResetCodeEmail, + sendAccountDeleteCodeEmail, sendSupportFeedbackEmail, + sendBookingLeadEmail, }; diff --git a/backend/src/services/geminiPlanner.service.js b/backend/src/services/geminiPlanner.service.js index be85f3d..4f9f700 100644 --- a/backend/src/services/geminiPlanner.service.js +++ b/backend/src/services/geminiPlanner.service.js @@ -1,5 +1,6 @@ const axios = require('axios'); const { logger } = require('../config/logger'); +const { AI_ITINERARY_SCHEMA, buildAiItineraryPrompt, mapAiItineraryToPlan } = require('./aiItinerary'); const DEFAULT_MODEL = 'gemini-2.5-flash'; const DEFAULT_API_BASE_URL = 'https://generativelanguage.googleapis.com'; @@ -308,4 +309,50 @@ async function refineTripPlanWithGemini({ basePlan, request }) { } } -module.exports = { refineTripPlanWithGemini }; +// To'liq marshrutni NOLDAN yaratadi (POI/destination ma'lumoti bo'lmaganda). +// refineTripPlanWithGemini faqat matnni yaxshilaydi; bu esa kun-marshrutni o'zi quradi. +async function generateTripPlanWithGemini(input) { + const config = readConfig(); + if (!config.enabled || !config.apiKey) return null; + + const url = `${config.apiBaseUrl.replace(/\/$/, '')}/v1beta/models/${encodeURIComponent( + config.model + )}:generateContent`; + + const body = { + contents: [{ role: 'user', parts: [{ text: buildAiItineraryPrompt(input) }] }], + generationConfig: { + temperature: 0.4, + topP: 0.9, + maxOutputTokens: 8192, + responseMimeType: 'application/json', + responseJsonSchema: AI_ITINERARY_SCHEMA, + // gemini-2.5-flash "thinking"ni o'chiramiz — generatsiya ancha tezlashadi. + thinkingConfig: { thinkingBudget: 0 }, + }, + }; + + try { + const response = await axios.post(url, body, { + timeout: Math.max(config.timeoutMs, 50000), + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': config.apiKey }, + }); + + const text = extractResponseText(response.data); + const parsed = tryParseJson(text); + const plan = mapAiItineraryToPlan(parsed, input, { provider: 'gemini', model: config.model }); + + if (!plan) { + logger.warn('Gemini planner returned unusable generation payload'); + return null; + } + return plan; + } catch (err) { + const status = err?.response?.status; + const message = err?.response?.data?.error?.message || err?.message || 'unknown Gemini error'; + logger.warn('Gemini planner generation failed', { status, message }); + return null; + } +} + +module.exports = { refineTripPlanWithGemini, generateTripPlanWithGemini }; diff --git a/backend/src/services/planner.service.js b/backend/src/services/planner.service.js index 9fb8671..8c15ade 100644 --- a/backend/src/services/planner.service.js +++ b/backend/src/services/planner.service.js @@ -1,5 +1,6 @@ const { prisma } = require('../config/database'); -const { refineTripPlanWithGemini } = require('./geminiPlanner.service'); +const { refineTripPlanWithGemini, generateTripPlanWithGemini } = require('./geminiPlanner.service'); +const { generateTripPlanWithClaude } = require('./claudePlanner.service'); const INTEREST_KEYWORDS = { tarixiy: ['history', 'historical', 'museum', 'ark', 'fortress', 'qala', 'madrasah', 'maqbara'], @@ -625,8 +626,26 @@ async function generateTripPlanBase({ }; } +function planHasItinerary(plan) { + return ( + plan && + Array.isArray(plan.days) && + plan.days.some((day) => Array.isArray(day.activities) && day.activities.length > 0) + ); +} + async function generateTripPlan(input) { const basePlan = await generateTripPlanBase(input); + + // POI/destination ma'lumoti bo'lmaganda (asosan outbound shaharlar) base bo'sh chiqadi — + // bunday holda AI noldan to'liq marshrut yaratadi. Avval Gemini (kalit mavjud), keyin Claude. + if (!planHasItinerary(basePlan)) { + const aiPlan = + (await generateTripPlanWithGemini(input).catch(() => null)) || + (await generateTripPlanWithClaude(input).catch(() => null)); + if (planHasItinerary(aiPlan)) return aiPlan; + } + const refinedPlan = await refineTripPlanWithGemini({ basePlan, request: input, diff --git a/backend/src/services/push.service.js b/backend/src/services/push.service.js new file mode 100644 index 0000000..107ace2 --- /dev/null +++ b/backend/src/services/push.service.js @@ -0,0 +1,110 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const axios = require('axios'); +const { logger } = require('../config/logger'); + +// Firebase Cloud Messaging HTTP v1 — to'g'ridan-to'g'ri (Expo push serverisiz). +// Service account JSON: FCM_SERVICE_ACCOUNT_PATH yoki /app/fcm-service-account.json. +const SA_PATH = process.env.FCM_SERVICE_ACCOUNT_PATH || `${process.cwd()}/fcm-service-account.json`; +const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; + +let serviceAccount = null; +let serviceAccountLoaded = false; +let cachedAccessToken = null; +let cachedTokenExpiry = 0; + +function loadServiceAccount() { + if (serviceAccountLoaded) return serviceAccount; + serviceAccountLoaded = true; + try { + serviceAccount = JSON.parse(fs.readFileSync(SA_PATH, 'utf8')); + } catch { + serviceAccount = null; + } + return serviceAccount; +} + +function base64url(input) { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +// Service account'dan OAuth2 access token (JWT bearer flow), ~55 daqiqa keshlanadi. +async function getAccessToken() { + const sa = loadServiceAccount(); + if (!sa) return null; + const now = Math.floor(Date.now() / 1000); + if (cachedAccessToken && now < cachedTokenExpiry - 60) return cachedAccessToken; + + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const claims = base64url( + JSON.stringify({ + iss: sa.client_email, + scope: FCM_SCOPE, + aud: sa.token_uri, + iat: now, + exp: now + 3600, + }) + ); + const signature = crypto + .createSign('RSA-SHA256') + .update(`${header}.${claims}`) + .sign(sa.private_key, 'base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + const assertion = `${header}.${claims}.${signature}`; + + const response = await axios.post( + sa.token_uri, + new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString(), + { headers: { 'content-type': 'application/x-www-form-urlencoded' }, timeout: 8000 } + ); + cachedAccessToken = response.data.access_token; + cachedTokenExpiry = now + (response.data.expires_in || 3600); + return cachedAccessToken; +} + +function isValidPushToken(token) { + return typeof token === 'string' && token.trim().length > 20; +} + +/** + * Bitta FCM push yuboradi. Fire-and-forget — hech qachon throw qilmaydi, + * shuning uchun booking status yangilash hech qachon buzilmaydi. + */ +async function sendPushNotification({ to, title, body, data }) { + if (!isValidPushToken(to)) return { sent: false, reason: 'invalid_or_missing_token' }; + const sa = loadServiceAccount(); + if (!sa) return { sent: false, reason: 'no_service_account' }; + + try { + const accessToken = await getAccessToken(); + if (!accessToken) return { sent: false, reason: 'no_access_token' }; + + // FCM data qiymatlari faqat string bo'lishi kerak + const stringData = {}; + if (data && typeof data === 'object') { + for (const [k, v] of Object.entries(data)) stringData[k] = String(v); + } + + await axios.post( + `https://fcm.googleapis.com/v1/projects/${sa.project_id}/messages:send`, + { + message: { + token: to.trim(), + notification: { title, body }, + data: stringData, + android: { priority: 'high', notification: { sound: 'default', channel_id: 'default' } }, + }, + }, + { headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, timeout: 8000 } + ); + return { sent: true }; + } catch (err) { + const detail = err.response?.data?.error?.message || err.message; + logger.warn('FCM push failed', { reason: detail }); + return { sent: false, reason: detail }; + } +} + +module.exports = { sendPushNotification, isValidPushToken }; diff --git a/backend/src/utils/agencyJwt.js b/backend/src/utils/agencyJwt.js index b73e81f..b0da84f 100644 --- a/backend/src/utils/agencyJwt.js +++ b/backend/src/utils/agencyJwt.js @@ -1,6 +1,10 @@ const jwt = require('jsonwebtoken'); -const SECRET = process.env.AGENCY_JWT_SECRET || process.env.JWT_SECRET || 'travelorai_agency_secret'; +// Fail-closed: prod'da agency yoki umumiy JWT siri majburiy. +if (process.env.NODE_ENV === 'production' && !process.env.AGENCY_JWT_SECRET && !process.env.JWT_SECRET) { + throw new Error('AGENCY_JWT_SECRET (or JWT_SECRET) is required in production'); +} +const SECRET = process.env.AGENCY_JWT_SECRET || process.env.JWT_SECRET || 'travelorai_agency_dev_only_secret'; const EXPIRES_IN = process.env.AGENCY_JWT_EXPIRES_IN || '7d'; function signAgencyToken(payload) { diff --git a/backend/src/utils/dataImage.js b/backend/src/utils/dataImage.js new file mode 100644 index 0000000..79d848a --- /dev/null +++ b/backend/src/utils/dataImage.js @@ -0,0 +1,38 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; +const IMAGE_TYPES = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +async function materializeDataImage(value, folder = 'agency') { + const text = String(value || '').trim(); + if (!text.startsWith('data:image/')) return text; + + const match = text.match(/^data:(image\/(?:jpeg|png|webp|gif));base64,([a-z0-9+/=\s]+)$/i); + if (!match) throw new Error('Rasm formati noto‘g‘ri'); + + const mime = match[1].toLowerCase(); + const extension = IMAGE_TYPES[mime]; + if (!extension) throw new Error('Faqat JPG, PNG, WEBP yoki GIF rasm qabul qilinadi'); + + const buffer = Buffer.from(match[2].replace(/\s/g, ''), 'base64'); + if (!buffer.length || buffer.length > MAX_IMAGE_BYTES) { + throw new Error('Rasm hajmi 8 MB dan oshmasligi kerak'); + } + + const safeFolder = String(folder || 'agency').replace(/[^a-z0-9_-]/gi, '') || 'agency'; + const uploadDir = path.resolve(__dirname, '../../uploads', safeFolder); + await fs.mkdir(uploadDir, { recursive: true }); + + const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`; + await fs.writeFile(path.join(uploadDir, filename), buffer); + return `/uploads/${safeFolder}/${filename}`; +} + +module.exports = { materializeDataImage }; diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js index 01b7f08..32ce415 100644 --- a/backend/src/utils/jwt.js +++ b/backend/src/utils/jwt.js @@ -1,6 +1,10 @@ const jwt = require('jsonwebtoken'); -const SECRET = process.env.JWT_SECRET || 'travelorai_secret'; +// Fail-closed: prod'da JWT_SECRET majburiy. Aks holda ma'lum zaxira sir token soxtalashtirishga yo'l ochardi. +if (process.env.NODE_ENV === 'production' && !process.env.JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required in production'); +} +const SECRET = process.env.JWT_SECRET || 'travelorai_dev_only_secret'; const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; function signToken(payload) { diff --git a/backend/src/utils/tourImage.js b/backend/src/utils/tourImage.js new file mode 100644 index 0000000..a678025 --- /dev/null +++ b/backend/src/utils/tourImage.js @@ -0,0 +1,40 @@ +const DEFAULT_TOUR_IMAGE = + 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=1400&q=80'; + +const TOUR_IMAGE_FALLBACKS = [ + { + terms: ['dubai', 'dubay', 'uae', 'birlashgan arab'], + imageUrl: 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?auto=format&fit=crop&w=1400&q=80', + }, + { + terms: ['samarkand', 'samarqand'], + imageUrl: 'https://images.unsplash.com/photo-1558981403-c5f9899a28bc?auto=format&fit=crop&w=1400&q=80', + }, + { + terms: ['bukhara', 'buxoro'], + imageUrl: 'https://images.unsplash.com/photo-1609412058473-978e48f6b2f8?auto=format&fit=crop&w=1400&q=80', + }, +]; + +function resolveTourImageUrl(tour) { + const explicitImage = String(tour?.imageUrl || '').trim(); + if (explicitImage) return explicitImage; + + const haystack = [ + tour?.title, + tour?.city, + tour?.subtitle, + tour?.description, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + const match = TOUR_IMAGE_FALLBACKS.find((fallback) => + fallback.terms.some((term) => haystack.includes(term)) + ); + + return match?.imageUrl || DEFAULT_TOUR_IMAGE; +} + +module.exports = { resolveTourImageUrl }; diff --git a/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg b/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg new file mode 100644 index 0000000..fddf98a Binary files /dev/null and b/backend/uploads/hero/1780753328834-15b70044964b2eca29.jpg differ diff --git a/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg b/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg new file mode 100644 index 0000000..8f137ff Binary files /dev/null and b/backend/uploads/hero/1780753713974-6ab90225253ba3d3d1.jpg differ diff --git a/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754193334-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754199246-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg b/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg new file mode 100644 index 0000000..0163eea Binary files /dev/null and b/backend/uploads/hero/1780754211901-ef7372849cfdf036be.jpg differ diff --git a/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg b/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg new file mode 100644 index 0000000..8f137ff Binary files /dev/null and b/backend/uploads/hero/1780754414430-6ab90225253ba3d3d1.jpg differ diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 0ed4942..d1be915 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -80,19 +80,21 @@ def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBu * this variant is about 6MiB larger per architecture than default. */ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' -def releaseVersionCode = ((findProperty('ANDROID_VERSION_CODE') ?: System.getenv('ANDROID_VERSION_CODE')) ?: '22').toInteger() -def releaseVersionName = ((findProperty('ANDROID_VERSION_NAME') ?: System.getenv('ANDROID_VERSION_NAME')) ?: '1.0.5').toString() +def releaseVersionCode = ((findProperty('ANDROID_VERSION_CODE') ?: System.getenv('ANDROID_VERSION_CODE')) ?: '25').toInteger() +def releaseVersionName = ((findProperty('ANDROID_VERSION_NAME') ?: System.getenv('ANDROID_VERSION_NAME')) ?: '1.0.7').toString() def uploadStoreFile = (findProperty('MYAPP_UPLOAD_STORE_FILE') ?: System.getenv('MYAPP_UPLOAD_STORE_FILE')) def uploadStorePassword = (findProperty('MYAPP_UPLOAD_STORE_PASSWORD') ?: System.getenv('MYAPP_UPLOAD_STORE_PASSWORD')) def uploadKeyAlias = (findProperty('MYAPP_UPLOAD_KEY_ALIAS') ?: System.getenv('MYAPP_UPLOAD_KEY_ALIAS')) def uploadKeyPassword = (findProperty('MYAPP_UPLOAD_KEY_PASSWORD') ?: System.getenv('MYAPP_UPLOAD_KEY_PASSWORD')) def hasReleaseSigning = uploadStoreFile && uploadStorePassword && uploadKeyAlias && uploadKeyPassword +// EAS Build o'z keystore'ini build.gradle'ga inject qiladi — u holda MYAPP_UPLOAD_* shart emas +def isEasBuild = System.getenv('EAS_BUILD') == 'true' def requestedTasks = gradle.startParameter.taskNames.collect { it.toLowerCase() } def isReleaseTaskRequested = requestedTasks.any { taskName -> taskName.contains("release") } -if (isReleaseTaskRequested && !hasReleaseSigning) { +if (isReleaseTaskRequested && !hasReleaseSigning && !isEasBuild) { throw new GradleException( "Release signing credentials are missing. Set MYAPP_UPLOAD_STORE_FILE, MYAPP_UPLOAD_STORE_PASSWORD, " + "MYAPP_UPLOAD_KEY_ALIAS, and MYAPP_UPLOAD_KEY_PASSWORD in ~/.gradle/gradle.properties or your environment." diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 9f8b819..070811d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -36,4 +36,4 @@ - + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml index d2603a2..4f5d3b7 100644 --- a/mobile/android/app/src/main/res/values/strings.xml +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ TravelorAI automatic - 1.0.4 + 1.0.7 contain false \ No newline at end of file diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 55f4fad..3ea6731 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { }.standardOutput.asText.get().trim() ).getParentFile().absolutePath includeBuild(reactNativeGradlePlugin) - + def expoPluginsPath = new File( providers.exec { workingDir(rootDir) diff --git a/mobile/app.json b/mobile/app.json index ba0f66a..6580829 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,10 +2,8 @@ "expo": { "name": "TravelorAI", "slug": "voyageai", - "version": "1.0.5", - "runtimeVersion": { - "policy": "appVersion" - }, + "version": "1.0.7", + "runtimeVersion": "1.0.7", "updates": { "url": "https://u.expo.dev/8e00aa9e-3ccd-42b8-80c3-3d221c05d2b6" }, @@ -19,7 +17,7 @@ "bundleIdentifier": "com.komiljonov.voyageai" }, "android": { - "versionCode": 22, + "versionCode": 25, "adaptiveIcon": { "backgroundColor": "#041A0F", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -27,10 +25,7 @@ }, "edgeToEdgeEnabled": true, "package": "com.komiljonov.voyageai", - "permissions": [ - "android.permission.ACCESS_COARSE_LOCATION", - "android.permission.ACCESS_FINE_LOCATION" - ] + "permissions": [] }, "web": { "output": "static", @@ -38,12 +33,6 @@ }, "plugins": [ "expo-router", - [ - "expo-location", - { - "locationWhenInUsePermission": "TravelorAI joylashuvingizni aniqlash uchun foydalanadi." - } - ], [ "expo-splash-screen", { diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 29324bb..d5ceab8 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { Tabs } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FONTS } from '../../src/constants/fonts'; +import { aiGlowShadow } from '../../src/constants/effects'; import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; export default function TabLayout() { @@ -19,15 +21,21 @@ export default function TabLayout() { screenOptions={({ route }) => ({ headerShown: false, tabBarStyle: styles.tabBar, - tabBarActiveTintColor: colors.primary, + tabBarActiveTintColor: colors.aiAccent, tabBarInactiveTintColor: colors.textMuted, tabBarLabelStyle: styles.label, tabBarItemStyle: styles.tabItem, + tabBarBackground: () => ( + + + + + ), tabBarIcon: ({ focused, color }) => { const icons: Record = { index: ['home', 'home-outline'], - planner: ['map', 'map-outline'], - explore: ['compass', 'compass-outline'], + tours: ['compass', 'compass-outline'], + trips: ['briefcase', 'briefcase-outline'], profile: ['person', 'person-outline'], }; @@ -44,9 +52,8 @@ export default function TabLayout() { })} > - - - + + ); @@ -57,7 +64,7 @@ function createStyles(colors: AppColors, bottomInset: number) { return StyleSheet.create({ tabBar: { - backgroundColor: colors.tabBar, + backgroundColor: 'transparent', borderTopWidth: 0, height: 66 + safeBottom, marginHorizontal: 18, @@ -68,10 +75,21 @@ function createStyles(colors: AppColors, bottomInset: number) { position: 'absolute', shadowColor: colors.shadow, shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.16, + shadowOpacity: 0.18, shadowRadius: 24, elevation: 14, }, + tabBarBg: { + ...StyleSheet.absoluteFillObject, + borderRadius: 26, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassStrong, + }, + tabBarTint: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.tabBar, + }, tabItem: { alignItems: 'center', justifyContent: 'flex-start', @@ -92,7 +110,8 @@ function createStyles(colors: AppColors, bottomInset: number) { justifyContent: 'center', }, iconSurfaceActive: { - backgroundColor: colors.primaryPale, + backgroundColor: colors.aiAccentPale, + ...aiGlowShadow(colors), }, label: { fontFamily: FONTS.medium, diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx deleted file mode 100644 index 7350018..0000000 --- a/mobile/app/(tabs)/explore.tsx +++ /dev/null @@ -1,3996 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Animated, - Easing, - LayoutAnimation, - ImageBackground, - Linking, - Platform, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; - -import * as Location from 'expo-location'; -import { Ionicons } from '@expo/vector-icons'; -import { Search } from '@metamorph/react-native-yamap'; -import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTranslation } from 'react-i18next'; - -import ExploreMap from '../../src/components/explore/ExploreMap'; -import type { ExploreCoordinate, ExploreMapMarker, ExploreMapViewport, ExploreRegion } from '../../src/components/explore/ExploreMap.types'; -import { CATEGORY_META, type MapPoint, type POISubtype, type POIType, SUB_CATEGORIES } from '../../src/constants/mapData'; -import { FONTS } from '../../src/constants/fonts'; -import { RADIUS, SPACING } from '../../src/constants/spacing'; -import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; -import { useWishlist } from '../../src/hooks/useWishlist'; -import { extractApiData } from '../../src/utils/auth'; -import { poiAPI, transportAPI, tripsAPI, yandexAPI, type PoiPayload, type YandexPlacePointPayload, type YandexTransportPointPayload } from '../../src/utils/api'; -import { getItem, getJSON, getUserKey, KEYS, saveItem, saveJSON } from '../../src/utils/storage'; -import type { TripPlan } from '../../src/utils/tripPlanner'; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -type CategoryFilter = 'all' | POIType; -type SubtypeFilter = 'all' | POISubtype; -type LocationAccessState = 'checking' | 'granted' | 'denied' | 'error'; -type ExploreLoadMode = 'radius' | 'viewport'; - -interface StoredUser { - id?: string | null; -} - -interface TripStop { - id: string; - dayNumber: number; - time: string; - title: string; - city: string; - point: MapPoint; -} - -interface ExploreListItem { - id: string; - title: string; - subtitle: string; - meta: string; - point: MapPoint; - dayNumber?: number; - time?: string; -} - -// ─── Constants ─────────────────────────────────────────────────────────────── - -const DEFAULT_REGION: ExploreRegion = { - latitude: 20, - longitude: 0, - latitudeDelta: 120, - longitudeDelta: 160, -}; - -const CITY_ALIAS_TO_CANONICAL: Record = { - xiva: 'khiva', - khiva: 'khiva', - hiva: 'khiva', - urganch: 'urgench', - urgench: 'urgench', - toshkent: 'tashkent', - tashkent: 'tashkent', - buxoro: 'bukhara', - bukhara: 'bukhara', - samarqand: 'samarkand', - samarkand: 'samarkand', - fargona: 'fergana', - fergana: 'fergana', - andijon: 'andijan', - andijan: 'andijan', - qarshi: 'karshi', - karshi: 'karshi', - termiz: 'termez', - termez: 'termez', - navoiy: 'navoi', - navoi: 'navoi', - jizzax: 'jizzakh', - jizzakh: 'jizzakh', - xorazm: 'khorezm', - khorezm: 'khorezm', -}; - -const RADIUS_OPTIONS = [5, 10, 20, 50, 100]; -const MIN_VIEWPORT_ZOOM = 10.2; -const YANDEX_RUNTIME_DATA_ENABLED = process.env.EXPO_PUBLIC_YANDEX_MAPKIT_ENABLED !== 'false'; -const YANDEX_STATIC_MAPS_API_KEY = - process.env.EXPO_PUBLIC_YANDEX_STATIC_MAPS_API_KEY || - process.env.EXPO_PUBLIC_YANDEX_MAPKIT_API_KEY || - ''; -const NATIVE_YANDEX_QUERIES: Record<'landmark' | 'restaurant' | 'hotel' | 'transport', Record> = { - landmark: { - all: ['достопримечательность', 'музей', 'историческое место', 'памятник', 'мечеть', 'attraction'], - historical: ['историческое место', 'музей', 'памятник'], - mosque: ['мечеть', 'masjid', 'mosque'], - other: ['достопримечательность', 'туристическое место', 'attraction'], - }, - restaurant: { - all: ['ресторан', 'кафе', 'узбекская кухня', 'миллий таомлар', 'халяль ресторан'], - traditional: ['узбекская кухня', 'миллий таомлар', 'национальная кухня'], - cafe: ['кафе', 'кофейня', 'cafe'], - budget: ['столовая', 'ошхона', 'фастфуд'], - mid: ['ресторан', 'restaurant'], - luxury: ['ресторан премиум', 'fine dining restaurant'], - }, - hotel: { - all: ['отель', 'гостиница', 'хостел', 'гостевой дом', 'hotel'], - budget: ['хостел', 'гостевой дом'], - mid: ['отель', 'гостиница', 'hotel'], - luxury: ['люкс отель', 'премиум отель', 'luxury hotel'], - }, - transport: { - all: ['остановка', 'автобусная остановка', 'вокзал', 'аэропорт', 'такси'], - bus: ['остановка', 'автобусная остановка'], - train: ['вокзал', 'железнодорожная станция'], - metro: ['метро'], - airport: ['аэропорт', 'airport'], - taxi: ['такси', 'taxi'], - }, -}; -interface ExploreFetchRequest { - key: string; - origin: ExploreCoordinate; - radiusKm: number; - params: Record; -} - -// ─── Icon helpers (no emoji) ───────────────────────────────────────────────── - -function getTypeColor(type: POIType, colors: AppColors): string { - return CATEGORY_META[type]?.markerColor || colors.primary; -} - -function getYandexPoiIconName(point: MapPoint): string { - const sub = (point.subtype ?? '').toLowerCase(); - if (sub === 'mosque') return 'moon-outline'; - if (sub === 'historical') return 'business-outline'; - if (sub === 'train') return 'train-outline'; - if (sub === 'airport') return 'airplane-outline'; - if (sub === 'bus') return 'bus-outline'; - if (sub === 'metro') return 'subway-outline'; - if (sub === 'taxi') return 'car-outline'; - if (sub === 'cafe') return 'cafe-outline'; - if (sub === 'luxury') return 'star-outline'; - if (point.type === 'transport') return 'bus-outline'; - if (point.type === 'restaurant') return 'restaurant-outline'; - if (point.type === 'hotel') return 'bed-outline'; - return 'location-outline'; -} - -function getYandexCategoryIconName(type: POIType): string { - if (type === 'transport') return 'bus-outline'; - if (type === 'restaurant') return 'restaurant-outline'; - if (type === 'hotel') return 'bed-outline'; - return 'location-outline'; -} - -function getYandexStaticMapPreviewUrl(lat: number, lng: number, width = 460, height = 260): string | null { - if (!YANDEX_STATIC_MAPS_API_KEY || !isValidCoordinate(lat, lng)) return null; - const params = new URLSearchParams({ - apikey: YANDEX_STATIC_MAPS_API_KEY, - lang: 'ru_RU', - ll: `${lng},${lat}`, - z: '16', - size: `${width},${height}`, - pt: `${lng},${lat},pm2rdm`, - }); - return `https://static-maps.yandex.ru/v1?${params.toString()}`; -} - -function getPointPreviewImageUrl(point: MapPoint, width = 460, height = 260): string | null { - return ( - point.imageUrl || - (point as any).photoUrl || - getYandexStaticMapPreviewUrl(point.lat, point.lng, width, height) - ); -} - -function getYandexSubtypeIconName(subtype: POISubtype): string { - return getYandexPoiIconName({ - id: subtype, - name: subtype, - city: '', - slug: subtype, - type: 'landmark', - subtype, - lat: 0, - lng: 0, - info: '', - icon: 'pin', - }); -} - -// ─── Utility functions ─────────────────────────────────────────────────────── - -function normalizeText(value: string): string { - return String(value || '') - .toLowerCase() - .replace(/['"`]/g, '') - .replace(/[^a-z0-9\u0400-\u04ff\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function canonicalCity(value: string): string { - const normalized = normalizeText(value) - .replace(/\b(shahri|city|region|viloyati|viloyat)\b/g, '') - .replace(/\s+/g, ' ') - .trim(); - if (!normalized) return ''; - return CITY_ALIAS_TO_CANONICAL[normalized] || normalized; -} - -function isValidCoordinate(lat: number, lng: number): boolean { - return ( - Number.isFinite(lat) && Number.isFinite(lng) && - lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 - ); -} - -function toFiniteNumber(value: unknown): number | null { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function extractActivityCoordinate(activity: any): ExploreCoordinate | null { - if (!activity || typeof activity !== 'object') return null; - - const lat = - toFiniteNumber(activity?.lat) ?? - toFiniteNumber(activity?.latitude) ?? - toFiniteNumber(activity?.location?.lat) ?? - toFiniteNumber(activity?.location?.latitude); - - const lng = - toFiniteNumber(activity?.lng) ?? - toFiniteNumber(activity?.lon) ?? - toFiniteNumber(activity?.longitude) ?? - toFiniteNumber(activity?.location?.lng) ?? - toFiniteNumber(activity?.location?.lon) ?? - toFiniteNumber(activity?.location?.longitude); - - if (lat == null || lng == null) return null; - if (!isValidCoordinate(lat, lng)) return null; - - return { latitude: lat, longitude: lng }; -} - -function cityEquals(a: string, b: string): boolean { - const left = canonicalCity(a); - const right = canonicalCity(b); - if (!left || !right) return false; - return left === right; -} - -function sanitizePoints(points: MapPoint[]): MapPoint[] { - return points.filter((p) => isValidCoordinate(Number(p.lat), Number(p.lng))); -} - -function resolveCityCenter( - city: string, - points: MapPoint[] -): ExploreCoordinate | null { - const cityKey = canonicalCity(city); - const validPoints = sanitizePoints(points); - if (cityKey) { - const byCity = validPoints.filter((point) => canonicalCity(point.city) === cityKey); - if (byCity.length > 0) { - const avgLat = byCity.reduce((sum, point) => sum + Number(point.lat), 0) / byCity.length; - const avgLng = byCity.reduce((sum, point) => sum + Number(point.lng), 0) / byCity.length; - if (isValidCoordinate(avgLat, avgLng)) { - return { latitude: avgLat, longitude: avgLng }; - } - } - - } - return null; -} - -function haversineKm(origin: ExploreCoordinate, target: ExploreCoordinate): number { - const R = 6371; - const toRad = (v: number) => (v * Math.PI) / 180; - const dLat = toRad(target.latitude - origin.latitude); - const dLng = toRad(target.longitude - origin.longitude); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(origin.latitude)) * Math.cos(toRad(target.latitude)) * - Math.sin(dLng / 2) ** 2; - return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -function filterPointsWithinRadius(points: MapPoint[], origin: ExploreCoordinate, radiusKm: number): MapPoint[] { - return points.filter((point) => - isValidCoordinate(point.lat, point.lng) && - haversineKm(origin, { latitude: point.lat, longitude: point.lng }) <= radiusKm - ); -} - -function buildRadiusRequest(origin: ExploreCoordinate, radiusKm: number): ExploreFetchRequest { - const normalizedRadiusKm = Number(radiusKm.toFixed(1)); - return { - key: `radius:${origin.latitude.toFixed(3)}:${origin.longitude.toFixed(3)}:${normalizedRadiusKm.toFixed(1)}`, - origin, - radiusKm: normalizedRadiusKm, - params: { - lat: origin.latitude, - lng: origin.longitude, - radiusKm: normalizedRadiusKm, - limit: 500, - }, - }; -} - -function getViewportRadiusCapKm(zoom: number): number | null { - if (!Number.isFinite(zoom) || zoom < MIN_VIEWPORT_ZOOM) return null; - if (zoom < 11) return 50; - if (zoom < 12) return 35; - if (zoom < 13) return 22; - if (zoom < 14) return 12; - return 6; -} - -function getViewportLimit(zoom: number): number { - if (zoom < 11) return 150; - if (zoom < 12) return 130; - if (zoom < 13) return 110; - return 90; -} - -function getViewportKeyPrecision(zoom: number): number { - if (zoom < 12) return 2; - if (zoom < 14) return 3; - return 4; -} - -function buildViewportRequest(viewport: ExploreMapViewport): ExploreFetchRequest | null { - const { center, bounds, zoom } = viewport; - const radiusCapKm = getViewportRadiusCapKm(zoom); - if (radiusCapKm == null) return null; - if (!isValidCoordinate(center.latitude, center.longitude)) return null; - if ( - !isValidCoordinate(bounds.northEast.latitude, bounds.northEast.longitude) || - !isValidCoordinate(bounds.southWest.latitude, bounds.southWest.longitude) - ) { - return null; - } - - const corners: ExploreCoordinate[] = [ - bounds.northEast, - bounds.southWest, - { latitude: bounds.northEast.latitude, longitude: bounds.southWest.longitude }, - { latitude: bounds.southWest.latitude, longitude: bounds.northEast.longitude }, - ]; - - const computedRadiusKm = Math.max( - ...corners.map((corner) => haversineKm(center, corner)), - 2 - ); - const normalizedRadiusKm = Number(Math.min(computedRadiusKm * 1.08, radiusCapKm).toFixed(1)); - const zoomBucket = Math.floor(zoom * 2) / 2; - const precision = getViewportKeyPrecision(zoom); - - return { - key: - `viewport:${center.latitude.toFixed(precision)}:${center.longitude.toFixed(precision)}` + - `:${normalizedRadiusKm.toFixed(1)}:${zoomBucket.toFixed(1)}`, - origin: center, - radiusKm: normalizedRadiusKm, - params: { - lat: center.latitude, - lng: center.longitude, - radiusKm: normalizedRadiusKm, - limit: getViewportLimit(zoom), - }, - }; -} - -function filterPointsWithinBounds( - points: MapPoint[], - bounds: ExploreMapViewport['bounds'] -): MapPoint[] { - const north = Math.max(bounds.northEast.latitude, bounds.southWest.latitude); - const south = Math.min(bounds.northEast.latitude, bounds.southWest.latitude); - const east = bounds.northEast.longitude; - const west = bounds.southWest.longitude; - const crossesDateLine = west > east; - - return points.filter((point) => { - if (!isValidCoordinate(point.lat, point.lng)) return false; - - const withinLatitude = point.lat >= south && point.lat <= north; - const withinLongitude = crossesDateLine - ? point.lng >= west || point.lng <= east - : point.lng >= west && point.lng <= east; - - return withinLatitude && withinLongitude; - }); -} - -function getViewportSearchOrigins(viewport: ExploreMapViewport | null, fallback: ExploreCoordinate): ExploreCoordinate[] { - const origins: ExploreCoordinate[] = [fallback]; - if (!viewport) return origins; - - const north = Math.max(viewport.bounds.northEast.latitude, viewport.bounds.southWest.latitude); - const south = Math.min(viewport.bounds.northEast.latitude, viewport.bounds.southWest.latitude); - const east = viewport.bounds.northEast.longitude; - const west = viewport.bounds.southWest.longitude; - const crossesDateLine = west > east; - - if (crossesDateLine) return origins; - - const latMid = (north + south) / 2; - const lngMid = (east + west) / 2; - const latQuarter = (north - south) / 4; - const lngQuarter = (east - west) / 4; - - [ - { latitude: latMid + latQuarter, longitude: lngMid - lngQuarter }, - { latitude: latMid + latQuarter, longitude: lngMid + lngQuarter }, - { latitude: latMid - latQuarter, longitude: lngMid - lngQuarter }, - { latitude: latMid - latQuarter, longitude: lngMid + lngQuarter }, - ].forEach((origin) => { - if (isValidCoordinate(origin.latitude, origin.longitude)) origins.push(origin); - }); - - return origins; -} - -function buildRegion(points: MapPoint[], userLocation: ExploreCoordinate | null): ExploreRegion { - const coords = [ - ...points.map((p) => ({ latitude: Number(p.lat), longitude: Number(p.lng) })), - ...(userLocation ? [userLocation] : []), - ].filter((c) => isValidCoordinate(c.latitude, c.longitude)); - - if (coords.length === 0) return DEFAULT_REGION; - - if (coords.length === 1) { - return { latitude: coords[0].latitude, longitude: coords[0].longitude, latitudeDelta: 0.18, longitudeDelta: 0.18 }; - } - - const lats = coords.map((c) => c.latitude); - const lngs = coords.map((c) => c.longitude); - const minLat = Math.min(...lats), maxLat = Math.max(...lats); - const minLng = Math.min(...lngs), maxLng = Math.max(...lngs); - - return { - latitude: (minLat + maxLat) / 2, - longitude: (minLng + maxLng) / 2, - latitudeDelta: Math.max((maxLat - minLat) * 1.6, 0.18), - longitudeDelta: Math.max((maxLng - minLng) * 1.6, 0.18), - }; -} - -function timeStringToMinutes(value: string): number { - const match = String(value || '').trim().match(/^(\d{1,2})[:.](\d{1,2})$/); - if (!match) return Number.MAX_SAFE_INTEGER; - const hours = Number(match[1]); - const minutes = Number(match[2]); - if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return Number.MAX_SAFE_INTEGER; - return Math.max(0, hours) * 60 + Math.max(0, minutes); -} - -function normalizeTrip(raw: any): TripPlan | null { - if (!raw || typeof raw !== 'object') return null; - const src = raw.planData && typeof raw.planData === 'object' ? { ...raw.planData, ...raw } : raw; - const progressSource = src?.progress || src?.planData?.progress || {}; - const updatedAt = String(src.updatedAt || src?.planData?.updatedAt || src.createdAt || new Date().toISOString()); - return { - id: String(src.id || ''), - title: String(src.title || 'Trip'), - totalCost: Number(src.totalCost || 0), - duration: Number(src.duration || 0), - travelers: Number(src.travelers || 1), - style: String(src.style || 'mid'), - destinations: Array.isArray(src.destinations) ? src.destinations.map(String) : [], - breakdown: { - transport: Number(src.breakdown?.transport || 0), - accommodation: Number(src.breakdown?.accommodation || 0), - food: Number(src.breakdown?.food || 0), - attractions: Number(src.breakdown?.attractions || 0), - misc: Number(src.breakdown?.misc || 0), - }, - days: Array.isArray(src.days) ? src.days : [], - warnings: Array.isArray(src.warnings) ? src.warnings.map(String) : [], - highlights: Array.isArray(src.highlights) ? src.highlights.map(String) : [], - tips: Array.isArray(src.tips) ? src.tips.map(String) : [], - status: src.status || src?.planData?.status, - source: src.source || src?.planData?.source, - syncStatus: src.syncStatus || src?.planData?.syncStatus, - updatedAt, - progress: { - visitedStopIds: Array.isArray(progressSource?.visitedStopIds) ? progressSource.visitedStopIds.map(String) : [], - notes: - progressSource?.notes && typeof progressSource.notes === 'object' && !Array.isArray(progressSource.notes) - ? progressSource.notes - : {}, - updatedAt: String(progressSource?.updatedAt || updatedAt), - }, - createdAt: String(src.createdAt || new Date().toISOString()), - }; -} - -function mapActivityTypeToPoiType(type: string): POIType | null { - const n = normalizeText(type); - if (n.includes('transport')) return 'transport'; - if (n.includes('food') || n.includes('restaurant')) return 'restaurant'; - if (n.includes('hotel') || n.includes('accommodation')) return 'hotel'; - if (n.includes('landmark') || n.includes('attraction') || n.includes('history')) return 'landmark'; - return null; -} - -function scorePoiByName(point: MapPoint, activityName: string, city: string, desiredType: POIType | null): number { - const nActivity = normalizeText(activityName); - const nPoint = normalizeText(point.name); - let score = 0; - if (city && cityEquals(point.city, city)) score += 80; - if (desiredType && point.type === desiredType) score += 26; - if (!nActivity) return score; - if (nPoint === nActivity) score += 140; - if (nPoint.includes(nActivity) || nActivity.includes(nPoint)) score += 92; - for (const term of nActivity.split(' ').filter(Boolean)) { - if (nPoint.includes(term)) score += 18; - } - if (normalizeText(point.info).includes(nActivity)) score += 24; - return score; -} - -function pickPoiForStep(points: MapPoint[], city: string, activityName: string, activityType: string): MapPoint | null { - const desiredType = mapActivityTypeToPoiType(activityType); - const sanitizedPoints = points.filter((p) => isValidCoordinate(p.lat, p.lng)); - const cityCandidates = sanitizedPoints.filter((p) => !city || cityEquals(p.city, city)); - const candidates = cityCandidates.length > 0 ? cityCandidates : sanitizedPoints; - if (candidates.length === 0) return null; - - const scored = candidates - .map((p) => ({ point: p, score: scorePoiByName(p, activityName, city, desiredType) })) - .filter((i) => i.score > 0) - .sort((a, b) => b.score - a.score); - - if (scored.length > 0) return scored[0].point; - if (desiredType) return candidates.find((p) => p.type === desiredType) || null; - return candidates[0] || null; -} - -function buildTripStops(trip: TripPlan | null, points: MapPoint[]): TripStop[] { - if (!trip) return []; - const stops: TripStop[] = []; - const seen = new Set(); - const allKnownPoints = sanitizePoints(points || []); - - (trip.days || []).forEach((day: any, dayIndex: number) => { - const dayNumber = Number(day?.day ?? day?.dayNumber ?? dayIndex + 1); - const dayCity = String(day?.destination || day?.city || trip.destinations?.[0] || '').trim(); - const activities = Array.isArray(day?.activities) ? day.activities : []; - - activities.forEach((activity: any, actIndex: number) => { - const activityName = String(activity?.name || '').trim(); - const coordinate = extractActivityCoordinate(activity); - const resolvedType = mapActivityTypeToPoiType(String(activity?.type || '')) || 'landmark'; - - const point = - (coordinate - ? { - id: `trip-stop-${dayNumber}-${actIndex}-${coordinate.latitude.toFixed(5)}-${coordinate.longitude.toFixed(5)}`, - name: activityName || `${dayCity || 'Route'} stop`, - city: dayCity || String(trip.destinations?.[0] || ''), - slug: `trip-stop-${dayNumber}-${actIndex}`, - type: resolvedType, - subtype: undefined, - lat: coordinate.latitude, - lng: coordinate.longitude, - info: '', - icon: 'pin', - } - : pickPoiForStep(allKnownPoints, dayCity, activityName, String(activity?.type || ''))); - - const fallbackCenter = !point ? resolveCityCenter(dayCity, allKnownPoints) : null; - const resolvedPoint = point || ( - fallbackCenter - ? { - id: `trip-fallback-${canonicalCity(dayCity) || 'city'}-${dayNumber}-${actIndex}`, - name: activityName || `${dayCity || 'Trip'} stop`, - city: dayCity || String(trip.destinations?.[0] || ''), - slug: `trip-fallback-${dayNumber}-${actIndex}`, - type: resolvedType, - subtype: undefined, - lat: fallbackCenter.latitude, - lng: fallbackCenter.longitude, - info: '', - icon: 'pin', - } - : null - ); - - if (!resolvedPoint) return; - const stopId = `${dayNumber}-${actIndex}-${resolvedPoint.id}`; - if (seen.has(stopId)) return; - seen.add(stopId); - stops.push({ - id: stopId, - dayNumber, - time: String(activity?.time || ''), - title: activityName || resolvedPoint.name, - city: resolvedPoint.city || dayCity, - point: resolvedPoint, - }); - }); - }); - - if (stops.length === 0) { - const routeCities = Array.from( - new Set( - [ - ...(Array.isArray(trip.destinations) ? trip.destinations : []), - ...((trip.days || []).map((day: any) => String(day?.destination || day?.city || ''))), - ] - .map((city) => String(city || '').trim()) - .filter(Boolean) - ) - ); - - routeCities.forEach((city, index) => { - const center = resolveCityCenter(city, allKnownPoints); - if (!center) return; - const fallbackPoint: MapPoint = { - id: `trip-city-center-${canonicalCity(city) || index}`, - name: city, - city, - slug: `trip-city-center-${canonicalCity(city) || index}`, - type: 'landmark', - subtype: undefined, - lat: center.latitude, - lng: center.longitude, - info: '', - icon: 'pin', - }; - const dayNumber = index + 1; - const stopId = `${dayNumber}-0-${fallbackPoint.id}`; - if (seen.has(stopId)) return; - seen.add(stopId); - stops.push({ - id: stopId, - dayNumber, - time: '', - title: city, - city, - point: fallbackPoint, - }); - }); - } - - return [...stops].sort((left, right) => { - if (left.dayNumber !== right.dayNumber) return left.dayNumber - right.dayNumber; - const leftTime = timeStringToMinutes(left.time); - const rightTime = timeStringToMinutes(right.time); - if (leftTime !== rightTime) return leftTime - rightTime; - return left.title.localeCompare(right.title); - }); -} - -function mapPoiToPoint(poi: PoiPayload): MapPoint { - return { - id: String(poi.id), - name: String(poi.name || ''), - city: String(poi.city || ''), - slug: String(poi.slug || poi.id || ''), - type: poi.type as POIType, - subtype: (poi.subtype || undefined) as POISubtype | undefined, - lat: Number(poi.lat), - lng: Number(poi.lng), - info: String(poi.info || ''), - description: poi.description ?? null, - imageUrl: poi.imageUrl ?? null, - price: poi.price ?? undefined, - priceLevel: poi.priceLevel ?? null, - rating: poi.rating ?? null, - ratingCount: poi.ratingCount ?? null, - phone: poi.phone ?? null, - website: poi.website ?? null, - openingHours: poi.openingHours ?? null, - gallery: poi.gallery ?? [], - icon: String(poi.icon || 'pin'), - source: poi.source ?? null, - sourceUrl: poi.sourceUrl ?? null, - lastVerifiedAt: poi.lastVerifiedAt ?? null, - confidenceScore: poi.confidenceScore ?? null, - }; -} - -function mapYandexRuntimePoint(item: YandexPlacePointPayload): MapPoint { - const id = String(item.id || `yandex-${item.type || 'poi'}-${item.lat}-${item.lng}`); - return { - ...mapPoiToPoint(item), - id, - slug: String(item.slug || id), - type: (item.type || 'landmark') as POIType, - subtype: (item.subtype || undefined) as POISubtype | undefined, - source: item.source || 'yandex_search', - sourceUrl: item.sourceUrl ?? null, - lastVerifiedAt: item.lastVerifiedAt ?? null, - confidenceScore: item.confidenceScore ?? 0.74, - distanceKm: item.distanceKm ?? null, - }; -} - -function mapYandexTransportToPoint(item: YandexTransportPointPayload): MapPoint { - return { - ...mapYandexRuntimePoint(item), - id: String(item.id || `yandex-transport-${item.lat}-${item.lng}`), - type: 'transport', - source: item.source || 'yandex_search', - confidenceScore: item.confidenceScore ?? 0.76, - }; -} - -function mergePoints(primary: MapPoint[], secondary: MapPoint[]): MapPoint[] { - const seen = new Set(); - const merged: MapPoint[] = []; - - [...primary, ...secondary].forEach((point) => { - if (!isValidCoordinate(point.lat, point.lng)) return; - const key = `${normalizeText(point.name)}:${point.type}:${point.subtype || ''}:${point.lat.toFixed(4)}:${point.lng.toFixed(4)}`; - if (seen.has(key)) return; - seen.add(key); - merged.push(point); - }); - - return merged; -} - -function nativeQueriesFor(type: 'landmark' | 'restaurant' | 'hotel' | 'transport', subtype?: string): string[] { - const querySet = NATIVE_YANDEX_QUERIES[type] || NATIVE_YANDEX_QUERIES.landmark; - return querySet[subtype || 'all'] || querySet.all; -} - -function slugify(value: string): string { - return normalizeText(value) - .replace(/[^a-z0-9\u0400-\u04ff]+/gi, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80); -} - -function mapNativeYandexSearchPoint( - item: any, - query: string, - type: 'landmark' | 'restaurant' | 'hotel' | 'transport' -): MapPoint | null { - const lat = Number(item?.lat ?? item?.point?.lat); - const lng = Number(item?.lon ?? item?.point?.lon); - const firstComponent = Array.isArray(item?.Components) ? item.Components[0] : null; - const name = String(item?.title || item?.name || firstComponent?.name || item?.formatted || '').trim(); - if (!name || !isValidCoordinate(lat, lng)) return null; - - const id = `yandex-native:${type}:${slugify(`${name}-${lat.toFixed(5)}-${lng.toFixed(5)}`)}`; - const subtitle = String(item?.subtitle || item?.formatted || '').trim(); - - return { - id, - name, - city: subtitle || 'Yandex MapKit', - slug: id, - type, - subtype: undefined, - lat, - lng, - info: subtitle || `Yandex MapKit result for ${query}`, - description: subtitle || null, - imageUrl: null, - rating: null, - ratingCount: null, - phone: null, - website: null, - openingHours: null, - gallery: [], - icon: type === 'restaurant' ? 'restaurant' : type === 'hotel' ? 'bed' : type === 'transport' ? 'bus' : 'pin', - source: 'yandex_mapkit_native', - sourceUrl: item?.uri || null, - lastVerifiedAt: new Date().toISOString(), - confidenceScore: 0.68, - }; -} - -async function fetchNativeYandexSearchPoints({ - origin, - origins, - type, - subtype, - limit, -}: { - origin: ExploreCoordinate; - origins?: ExploreCoordinate[]; - type: 'landmark' | 'restaurant' | 'hotel' | 'transport'; - subtype?: string; - limit: number; -}): Promise { - if (Platform.OS === 'web' || !Search?.searchText) return []; - - const queries = nativeQueriesFor(type, subtype).slice(0, 6); - const searchOrigins = (origins && origins.length > 0 ? origins : [origin]).slice(0, 5); - const options = { - disableSpellingCorrection: false, - geometry: true, - searchTypes: type === 'landmark' ? 3 : 2, - } as any; - - const responses = await Promise.all( - searchOrigins.flatMap((searchOrigin) => queries.map(async (query) => { - try { - const figure = { - type: 'POINT', - value: { lat: searchOrigin.latitude, lon: searchOrigin.longitude }, - } as any; - const items = await Search.searchText(query, figure, options); - const list = Array.isArray(items) ? items : items ? [items] : []; - return list - .map((item) => mapNativeYandexSearchPoint(item, query, type)) - .filter(Boolean) as MapPoint[]; - } catch { - return []; - } - })) - ); - - return mergePoints(responses.flat(), []).slice(0, limit); -} - -async function fetchNativeYandexTextSearchPoints({ - query, - origin, - type, - limit, -}: { - query: string; - origin: ExploreCoordinate; - type?: 'landmark' | 'restaurant' | 'hotel' | 'transport'; - limit: number; -}): Promise { - if (Platform.OS === 'web' || !Search?.searchText || query.trim().length < 2) return []; - - const fallbackType = type || 'landmark'; - const figure = { - type: 'POINT', - value: { lat: origin.latitude, lon: origin.longitude }, - } as any; - const options = { - disableSpellingCorrection: false, - geometry: true, - searchTypes: type && type !== 'landmark' ? 2 : 3, - } as any; - - try { - const items = await Search.searchText(query, figure, options); - const list = Array.isArray(items) ? items : items ? [items] : []; - const mapped = list - .map((item) => mapNativeYandexSearchPoint(item, query, fallbackType)) - .filter(Boolean) as MapPoint[]; - return mergePoints(mapped, []).slice(0, limit); - } catch { - return []; - } -} - -function formatSourceLabel(source?: string | null): string | null { - const normalized = normalizeText(source || ''); - if (!normalized) return null; - if (normalized.includes('yandex')) return 'Yandex'; - if (normalized.includes('travelorai')) return 'TravelorAI verified'; - return source || null; -} - -function isLegacyProviderSource(source?: string | null): boolean { - const normalized = normalizeText(source || ''); - return normalized.includes('google_places') || normalized.includes('google places') || normalized.includes('mapbox') || normalized.includes('2gis'); -} - -function formatConfidence(score?: number | null): string | null { - if (score == null || !Number.isFinite(Number(score))) return null; - const normalized = Number(score) <= 1 ? Number(score) * 100 : Number(score); - return `${Math.round(Math.max(0, Math.min(normalized, 100)))}%`; -} - -function formatLastUpdated(value?: string | null): string | null { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function CatPressable({ - active, - onPress, - style, - activeStyle, - children, -}: { - active: boolean; - onPress: () => void; - style: object | object[]; - activeStyle: object; - children: React.ReactNode; -}) { - const scale = useRef(new Animated.Value(1)).current; - const onIn = () => Animated.spring(scale, { toValue: 0.92, useNativeDriver: true, speed: 40, bounciness: 0 }).start(); - const onOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 18, bounciness: 6 }).start(); - - return ( - - - {children} - - - ); -} - -// ─── Main screen ────────────────────────────────────────────────────────────── - -function FadeInRow({ index, version, children }: { index: number; version: number; children: React.ReactNode }) { - const anim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - anim.setValue(0); - Animated.timing(anim, { - toValue: 1, - duration: 240, - delay: Math.min(index * 35, 350), - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }, [anim, index, version]); - - const translateY = anim.interpolate({ inputRange: [0, 1], outputRange: [8, 0] }); - - return ( - - {children} - - ); -} - -function HeartBtn({ - wished, onPress, style, wishedStyle, size, defaultColor, activeColor, -}: { - wished: boolean; onPress: () => void; style: object; wishedStyle: object; - size: number; defaultColor: string; activeColor: string; -}) { - const scale = useRef(new Animated.Value(1)).current; - - const handlePress = () => { - Animated.sequence([ - Animated.spring(scale, { toValue: 1.4, useNativeDriver: true, speed: 45, bounciness: 0 }), - Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 16, bounciness: 10 }), - ]).start(); - onPress(); - }; - - return ( - - - - - - ); -} - -function PlacePreviewImage({ - point, - styles, - colors, -}: { - point: MapPoint; - styles: ReturnType; - colors: AppColors; -}) { - const [failed, setFailed] = useState(false); - const imageUrl = failed ? null : getPointPreviewImageUrl(point); - - if (imageUrl) { - return ( - setFailed(true)} - > - - - {point.imageUrl ? 'Photo' : 'Map'} - - - - ); - } - - return ( - - - - ); -} - -export default function ExploreScreen() { - const { colors, resolvedTheme } = useAppTheme(); - const { t } = useTranslation(); - const insets = useSafeAreaInsets(); - const safeBottom = Math.max(insets.bottom, 22); - const styles = useMemo(() => createStyles(colors), [colors]); - const { tripId } = useLocalSearchParams<{ tripId?: string }>(); - - const tt = useCallback( - (key: string, fallback: string, values?: Record) => - t(key as any, { defaultValue: fallback, ...(values || {}) }), - [t] - ); - - // ── State ───────────────────────────────────────────────────────────── - const [userId, setUserId] = useState(null); - const [points, setPoints] = useState([]); - const [yandexRuntimePoints, setYandexRuntimePoints] = useState([]); - const [yandexRuntimeRefreshing, setYandexRuntimeRefreshing] = useState(false); - const [yandexSearchPoints, setYandexSearchPoints] = useState([]); - const [yandexSearchRefreshing, setYandexSearchRefreshing] = useState(false); - const [yandexTransportPoints, setYandexTransportPoints] = useState([]); - const [yandexTransportRefreshing, setYandexTransportRefreshing] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [query, setQuery] = useState(''); - const [category, setCategory] = useState('all'); - const [subtype, setSubtype] = useState('all'); - const [selectedId, setSelectedId] = useState(null); - const [focusedMapId, setFocusedMapId] = useState(null); - const [previewPointId, setPreviewPointId] = useState(null); - const [detailPointId, setDetailPointId] = useState(null); - const [routePreviewPointId, setRoutePreviewPointId] = useState(null); - const [userLocation, setUserLocation] = useState(null); - const [locationAccess, setLocationAccess] = useState('checking'); - const [locationError, setLocationError] = useState(null); - const [locating, setLocating] = useState(false); - const [loadMode, setLoadMode] = useState('viewport'); - const [radiusKm, setRadiusKm] = useState(100); - const [scopePanelOpen, setScopePanelOpen] = useState(false); - const [mapViewport, setMapViewport] = useState(null); - const [recenterToInitialRegionSignal, setRecenterToInitialRegionSignal] = useState(0); - const [recenterToUserLocationSignal, setRecenterToUserLocationSignal] = useState(0); - const [refreshingPlaces, setRefreshingPlaces] = useState(false); - const [trip, setTrip] = useState(null); - const [tripLoading, setTripLoading] = useState(false); - const [activeDay, setActiveDay] = useState(null); - const [savingStopId, setSavingStopId] = useState(null); - const [listVersion, setListVersion] = useState(0); - - const subchipsAnim = useRef(new Animated.Value(0)).current; - const locationWatcherRef = useRef(null); - const lastPlacesRequestKeyRef = useRef(null); - const lastYandexRuntimeRequestKeyRef = useRef(null); - const lastYandexSearchRequestKeyRef = useRef(null); - const lastYandexTransportRequestKeyRef = useRef(null); - const placesRequestIdRef = useRef(0); - const yandexRuntimeRequestIdRef = useRef(0); - const yandexSearchRequestIdRef = useRef(0); - const yandexTransportRequestIdRef = useRef(0); - const tripStorageKey = useMemo(() => (userId ? getUserKey(userId, KEYS.TRIPS) : KEYS.TRIPS), [userId]); - - // ── Init: user + saved radius ───────────────────────────────────────── - useEffect(() => { - let alive = true; - (async () => { - const [user, savedRadius] = await Promise.all([getJSON(KEYS.USER), getItem(KEYS.EXPLORE_RADIUS)]); - if (!alive) return; - setUserId(user?.id ?? null); - const r = Number(savedRadius); - if (RADIUS_OPTIONS.includes(r)) { - setRadiusKm(r); - } else if (r > 100) { - setRadiusKm(100); - } - })(); - return () => { alive = false; }; - }, []); - - useEffect(() => { - saveItem(KEYS.EXPLORE_RADIUS, String(radiusKm)).catch(() => {}); - }, [radiusKm]); - - useFocusEffect( - useCallback(() => { - if (!tripId) { - setRecenterToInitialRegionSignal((value) => value + 1); - } - }, [tripId]) - ); - - // ── Load POI ────────────────────────────────────────────────────────── - const loadPoints = useCallback(async (opts?: { silent?: boolean; force?: boolean; viewport?: ExploreMapViewport | null }) => { - const silent = Boolean(opts?.silent); - const nextViewport = opts?.viewport ?? mapViewport; - const currentRequest = - tripId - ? null - : loadMode === 'radius' - ? (userLocation ? buildRadiusRequest(userLocation, radiusKm) : null) - : (nextViewport ? buildViewportRequest(nextViewport) : null); - - if (!tripId && loadMode === 'viewport' && !currentRequest) { - lastPlacesRequestKeyRef.current = null; - setError(null); - setRefreshingPlaces(false); - setLoading(false); - return; - } - - const requestKey = tripId ? 'trip:all' : `${currentRequest?.key}:${category}:${subtype}`; - if (!requestKey) { - setRefreshingPlaces(false); - setLoading(false); - return; - } - - if (!opts?.force && lastPlacesRequestKeyRef.current === requestKey) { - setRefreshingPlaces(false); - setLoading(false); - return; - } - - lastPlacesRequestKeyRef.current = requestKey; - if (!silent) setLoading(true); - else setRefreshingPlaces(true); - setError(null); - - const cached = await getJSON(KEYS.POI_CACHE_V2); - const cachedFromStorage = sanitizePoints(Array.isArray(cached) ? cached : []); - const cachedPoints = cachedFromStorage; - const fallbackPoints = tripId - ? cachedPoints - : currentRequest - ? loadMode === 'viewport' && nextViewport - ? filterPointsWithinBounds(cachedPoints, nextViewport.bounds) - : filterPointsWithinRadius(cachedPoints, currentRequest.origin, currentRequest.radiusKm) - : []; - const requestId = ++placesRequestIdRef.current; - - try { - const response = extractApiData( - await poiAPI.getAll(tripId ? { limit: 500 } : currentRequest?.params) - ); - const items = Array.isArray(response?.items) ? response.items : Array.isArray(response) ? response : []; - const normalized = sanitizePoints( - items - .filter((item: PoiPayload) => !isLegacyProviderSource(item.source)) - .map((item: PoiPayload) => mapPoiToPoint(item)) - ); - const scopedNormalized = !tripId && loadMode === 'viewport' && nextViewport - ? filterPointsWithinBounds(normalized, nextViewport.bounds) - : normalized; - if (requestId !== placesRequestIdRef.current) return; - - if (scopedNormalized.length > 0) { - if (tripId) { - await saveJSON(KEYS.POI_CACHE_V2, normalized); - } - setPoints(scopedNormalized); - setError(null); - } else if (fallbackPoints.length > 0) { - setPoints(fallbackPoints); - setError(null); - } else { - setPoints([]); - setError(null); - } - } catch { - if (requestId !== placesRequestIdRef.current) return; - if (fallbackPoints.length > 0) { - setPoints(fallbackPoints); - setError(null); - } else if (cachedPoints.length > 0) { - setPoints(cachedPoints); - setError(null); - } else { - setPoints([]); - setError(null); - } - } finally { - if (requestId === placesRequestIdRef.current) { - if (!silent) setLoading(false); - setRefreshingPlaces(false); - } - } - }, [category, loadMode, mapViewport, radiusKm, subtype, tripId, userLocation]); - - useEffect(() => { - const timer = setTimeout(() => { - void loadPoints({ silent: points.length > 0 }); - }, loadMode === 'viewport' ? 240 : 140); - - return () => clearTimeout(timer); - }, [loadMode, loadPoints, mapViewport, points.length, radiusKm, tripId, userLocation]); - - const ensureUserLocation = useCallback(async (): Promise => { - try { - setLocating(true); - setLocationError(null); - - let permission = await Location.getForegroundPermissionsAsync(); - if (permission.status !== 'granted') { - permission = await Location.requestForegroundPermissionsAsync(); - } - - if (permission.status !== 'granted') { - setLocationAccess('denied'); - return false; - } - - const current = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.Balanced, - }); - - setUserLocation({ - latitude: current.coords.latitude, - longitude: current.coords.longitude, - }); - setLocationAccess('granted'); - return true; - } catch { - setLocationAccess('error'); - setLocationError(tt('explore.locationErrorMsg', 'Could not get your location.')); - return false; - } finally { - setLocating(false); - } - }, [tt]); - - useEffect(() => { - if (locationAccess !== 'granted') { - locationWatcherRef.current?.remove(); - locationWatcherRef.current = null; - return; - } - - let cancelled = false; - - (async () => { - try { - const subscription = await Location.watchPositionAsync( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 25, - }, - (position) => { - setUserLocation({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - }); - } - ); - - if (cancelled) { - subscription.remove(); - return; - } - - locationWatcherRef.current?.remove(); - locationWatcherRef.current = subscription; - } catch { - setLocationError(tt('explore.locationErrorMsg', 'Could not get your location.')); - } - })(); - - return () => { - cancelled = true; - locationWatcherRef.current?.remove(); - locationWatcherRef.current = null; - }; - }, [locationAccess, tt]); - - // ── Load trip ───────────────────────────────────────────────────────── - useEffect(() => { - let alive = true; - if (!tripId) { setTrip(null); setTripLoading(false); return () => { alive = false; }; } - - (async () => { - setTripLoading(true); - const scopedKey = userId ? getUserKey(userId, KEYS.TRIPS) : KEYS.TRIPS; - const cachedTrips = await getJSON(scopedKey); - const cachedTrip = Array.isArray(cachedTrips) - ? normalizeTrip(cachedTrips.find((i) => String(i?.id) === String(tripId))) - : null; - - if (alive && cachedTrip) setTrip(cachedTrip); - - try { - const token = await getItem(KEYS.TOKEN); - if (!token) { if (alive) setTrip(cachedTrip); return; } - - const response = await tripsAPI.getAll(); - const payload = extractApiData(response); - const items: any[] = Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : []; - const nextTrip = normalizeTrip(items.find((i) => String(i?.id) === String(tripId))); - - if (alive) setTrip(nextTrip || cachedTrip); - } catch { - if (alive) setTrip(cachedTrip); - } finally { - if (alive) setTripLoading(false); - } - })(); - - return () => { alive = false; }; - }, [tripId, userId]); - - const { toggle: toggleWishlist, isWishlisted } = useWishlist(userId); - - // ── Sub-categories for selected category ───────────────────────────── - const visibleSubcategories = useMemo(() => { - if (category === 'all') return []; - return SUB_CATEGORIES[category] || []; - }, [category]); - - useEffect(() => { - if (category === 'all') { setSubtype('all'); return; } - if (!visibleSubcategories.some((s) => s.key === subtype)) setSubtype('all'); - }, [category, subtype, visibleSubcategories]); - - useEffect(() => { - Animated.timing(subchipsAnim, { - toValue: visibleSubcategories.length > 0 ? 1 : 0, - duration: 200, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - }, [subchipsAnim, visibleSubcategories.length]); - - useEffect(() => { - if (previewPointId) { - setScopePanelOpen(false); - } - }, [previewPointId]); - - // ── Filter handlers ─────────────────────────────────────────────────── - const resetExploreSelection = useCallback(() => { - setFocusedMapId(null); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - setSelectedId(null); - }, []); - - const handleSetCategory = useCallback((key: CategoryFilter) => { - LayoutAnimation.configureNext({ - duration: 260, - create: { type: 'easeInEaseOut', property: 'opacity' }, - update: { type: 'spring', springDamping: 0.8 }, - delete: { type: 'easeInEaseOut', property: 'opacity' }, - }); - setListVersion((v) => v + 1); - setCategory(key); - resetExploreSelection(); - }, [resetExploreSelection]); - - const handleSetSubtype = useCallback((key: SubtypeFilter) => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setListVersion((v) => v + 1); - setSubtype(key); - resetExploreSelection(); - }, [resetExploreSelection]); - - const radiusIndex = Math.max(RADIUS_OPTIONS.indexOf(radiusKm), 0); - const canDecreaseRadius = radiusIndex > 0; - const canIncreaseRadius = radiusIndex < RADIUS_OPTIONS.length - 1; - const viewportRequest = useMemo( - () => (mapViewport ? buildViewportRequest(mapViewport) : null), - [mapViewport] - ); - - const yandexRuntimeRequest = useMemo(() => { - if (tripId || category === 'transport' || !YANDEX_RUNTIME_DATA_ENABLED) return null; - if (loadMode === 'radius') { - return userLocation ? buildRadiusRequest(userLocation, radiusKm) : null; - } - return viewportRequest; - }, [category, loadMode, radiusKm, tripId, userLocation, viewportRequest]); - - useEffect(() => { - if (!yandexRuntimeRequest) { - lastYandexRuntimeRequestKeyRef.current = null; - setYandexRuntimePoints([]); - setYandexRuntimeRefreshing(false); - return; - } - - const requestTypes: ('landmark' | 'restaurant' | 'hotel')[] = - category === 'all' ? ['landmark', 'restaurant', 'hotel'] : [category as 'landmark' | 'restaurant' | 'hotel']; - const requestSubtype = subtype !== 'all' ? subtype : undefined; - const requestKey = `yandex:${yandexRuntimeRequest.key}:${requestTypes.join(',')}:${requestSubtype || 'all'}`; - if (lastYandexRuntimeRequestKeyRef.current === requestKey) return; - - let alive = true; - const requestId = ++yandexRuntimeRequestIdRef.current; - lastYandexRuntimeRequestKeyRef.current = requestKey; - setYandexRuntimeRefreshing(true); - - (async () => { - try { - const requestLimit = Number(yandexRuntimeRequest.params.limit || getViewportLimit(mapViewport?.zoom || MIN_VIEWPORT_ZOOM)); - const responses = await Promise.all( - requestTypes.map((type) => - yandexAPI.getPlacesNearby({ - lat: yandexRuntimeRequest.origin.latitude, - lng: yandexRuntimeRequest.origin.longitude, - radiusKm: yandexRuntimeRequest.radiusKm, - type, - subtype: requestSubtype, - limit: requestLimit, - }) - ) - ); - if (!alive || requestId !== yandexRuntimeRequestIdRef.current) return; - const normalized = sanitizePoints( - responses.flatMap((response) => { - const payload = extractApiData(response); - const items = Array.isArray(payload?.items) ? payload.items : []; - return items.map((item: YandexPlacePointPayload) => mapYandexRuntimePoint(item)); - }) - ); - let combined = normalized; - if (combined.length < requestTypes.length * 8) { - const nativeOrigins = - loadMode === 'viewport' && mapViewport - ? getViewportSearchOrigins(mapViewport, yandexRuntimeRequest.origin) - : [yandexRuntimeRequest.origin]; - const nativeResponses = await Promise.all( - requestTypes.map((type) => - fetchNativeYandexSearchPoints({ - origin: yandexRuntimeRequest.origin, - origins: nativeOrigins, - type, - subtype: requestSubtype, - limit: requestLimit, - }) - ) - ); - if (!alive || requestId !== yandexRuntimeRequestIdRef.current) return; - combined = mergePoints(nativeResponses.flat(), normalized); - if (typeof __DEV__ !== 'undefined' && __DEV__) { - console.info('[Explore] Yandex native fallback', { - category, - subtype: requestSubtype || 'all', - origins: nativeOrigins.length, - backend: normalized.length, - native: nativeResponses.flat().length, - combined: combined.length, - }); - } - } - const scoped = loadMode === 'viewport' && mapViewport - ? filterPointsWithinBounds(combined, mapViewport.bounds) - : filterPointsWithinRadius(combined, yandexRuntimeRequest.origin, yandexRuntimeRequest.radiusKm); - setYandexRuntimePoints(scoped); - } catch { - if (alive && requestId === yandexRuntimeRequestIdRef.current) { - setYandexRuntimePoints([]); - } - } finally { - if (alive && requestId === yandexRuntimeRequestIdRef.current) { - setYandexRuntimeRefreshing(false); - } - } - })(); - - return () => { - alive = false; - }; - }, [category, loadMode, mapViewport, subtype, yandexRuntimeRequest]); - - useEffect(() => { - const searchText = query.trim(); - const normalizedQuery = normalizeText(searchText); - - if (tripId || normalizedQuery.length < 2 || !YANDEX_RUNTIME_DATA_ENABLED) { - lastYandexSearchRequestKeyRef.current = null; - setYandexSearchPoints([]); - setYandexSearchRefreshing(false); - return; - } - - const center = - userLocation || - (mapViewport && mapViewport.zoom >= MIN_VIEWPORT_ZOOM ? mapViewport.center : null); - const requestType = category === 'all' ? undefined : category; - const requestRadiusKm = loadMode === 'radius' ? radiusKm : 25; - const requestKey = [ - 'yandex-search', - normalizedQuery, - requestType || 'all', - center ? `${center.latitude.toFixed(4)}:${center.longitude.toFixed(4)}:${requestRadiusKm}` : 'global', - ].join(':'); - - let alive = true; - const timer = setTimeout(() => { - if (lastYandexSearchRequestKeyRef.current === requestKey) return; - - const requestId = ++yandexSearchRequestIdRef.current; - lastYandexSearchRequestKeyRef.current = requestKey; - setYandexSearchRefreshing(true); - - (async () => { - try { - const response = extractApiData( - await yandexAPI.searchPlaces({ - query: searchText, - type: requestType, - ...(center - ? { - lat: center.latitude, - lng: center.longitude, - radiusKm: requestRadiusKm, - } - : {}), - }) - ); - if (!alive || requestId !== yandexSearchRequestIdRef.current) return; - - const items = Array.isArray(response?.items) ? response.items : []; - let searchPoints = sanitizePoints(items.map((item: YandexPlacePointPayload) => mapYandexRuntimePoint(item))); - if (center && searchPoints.length < 8) { - const nativePoints = await fetchNativeYandexTextSearchPoints({ - query: searchText, - origin: center, - type: requestType as 'landmark' | 'restaurant' | 'hotel' | 'transport' | undefined, - limit: 40, - }); - if (!alive || requestId !== yandexSearchRequestIdRef.current) return; - searchPoints = mergePoints(nativePoints, searchPoints); - } - setYandexSearchPoints(searchPoints); - if (searchPoints[0]) { - setFocusedMapId(searchPoints[0].id); - setSelectedId(searchPoints[0].id); - } - } catch { - if (alive && requestId === yandexSearchRequestIdRef.current) { - setYandexSearchPoints([]); - } - } finally { - if (alive && requestId === yandexSearchRequestIdRef.current) { - setYandexSearchRefreshing(false); - } - } - })(); - }, 320); - - return () => { - alive = false; - clearTimeout(timer); - }; - }, [category, loadMode, mapViewport, query, radiusKm, tripId, userLocation]); - - const yandexTransportRequest = useMemo(() => { - if (tripId || category !== 'transport' || !YANDEX_RUNTIME_DATA_ENABLED) return null; - if (loadMode === 'radius') { - return userLocation ? buildRadiusRequest(userLocation, radiusKm) : null; - } - return viewportRequest; - }, [category, loadMode, radiusKm, tripId, userLocation, viewportRequest]); - - useEffect(() => { - if (!yandexTransportRequest) { - lastYandexTransportRequestKeyRef.current = null; - setYandexTransportPoints([]); - setYandexTransportRefreshing(false); - return; - } - - const yandexType = subtype !== 'all' ? subtype : undefined; - const requestKey = `yandex-transport:${yandexTransportRequest.key}:${yandexType || 'all'}`; - if (lastYandexTransportRequestKeyRef.current === requestKey) return; - - let alive = true; - const requestId = ++yandexTransportRequestIdRef.current; - lastYandexTransportRequestKeyRef.current = requestKey; - setYandexTransportRefreshing(true); - - (async () => { - try { - const response = extractApiData( - await transportAPI.getYandexNearby({ - lat: yandexTransportRequest.origin.latitude, - lng: yandexTransportRequest.origin.longitude, - radiusKm: yandexTransportRequest.radiusKm, - type: yandexType, - limit: Number(yandexTransportRequest.params.limit || getViewportLimit(mapViewport?.zoom || MIN_VIEWPORT_ZOOM)), - }) - ); - if (!alive || requestId !== yandexTransportRequestIdRef.current) return; - const items = Array.isArray(response?.items) ? response.items : []; - const normalized = sanitizePoints(items.map((item: YandexTransportPointPayload) => mapYandexTransportToPoint(item))); - const scoped = loadMode === 'viewport' && mapViewport - ? filterPointsWithinBounds(normalized, mapViewport.bounds) - : normalized; - setYandexTransportPoints(scoped); - } catch { - if (alive && requestId === yandexTransportRequestIdRef.current) { - setYandexTransportPoints([]); - } - } finally { - if (alive && requestId === yandexTransportRequestIdRef.current) { - setYandexTransportRefreshing(false); - } - } - })(); - - return () => { - alive = false; - }; - }, [loadMode, mapViewport, subtype, yandexTransportRequest]); - - const scopedPoints = useMemo(() => { - const withYandexRuntime = category === 'transport' - ? points - : mergePoints(yandexRuntimePoints, points); - const basePoints = category === 'transport' - ? mergePoints(yandexTransportPoints, withYandexRuntime) - : withYandexRuntime; - const withSearchResults = normalizeText(query).length >= 2 - ? mergePoints(yandexSearchPoints, basePoints) - : basePoints; - if (tripId || normalizeText(query).length >= 2 || loadMode !== 'viewport' || !mapViewport) return withSearchResults; - if (mapViewport.zoom < MIN_VIEWPORT_ZOOM) return []; - return filterPointsWithinBounds(withSearchResults, mapViewport.bounds); - }, [category, loadMode, mapViewport, points, query, tripId, yandexRuntimePoints, yandexSearchPoints, yandexTransportPoints]); - - // ── Filtered + sorted points ────────────────────────────────────────── - const pointMeta = useMemo(() => { - const nQuery = normalizeText(query); - - return scopedPoints.map((point) => { - const distanceKm = userLocation && isValidCoordinate(point.lat, point.lng) - ? haversineKm(userLocation, { latitude: point.lat, longitude: point.lng }) - : null; - - let queryScore = 0; - if (nQuery) { - if (normalizeText(point.name) === nQuery) queryScore += 120; - if (normalizeText(point.name).includes(nQuery)) queryScore += 80; - if (normalizeText(point.city).includes(nQuery)) queryScore += 40; - if (normalizeText(point.info).includes(nQuery)) queryScore += 20; - } - - return { point, distanceKm, queryScore }; - }); - }, [query, scopedPoints, userLocation]); - - const nearbyPointMeta = useMemo(() => { - const nQuery = normalizeText(query); - - return pointMeta - .filter(({ queryScore }) => (nQuery ? queryScore > 0 : true)) - .filter(({ distanceKm }) => { - if (tripId || loadMode !== 'radius' || !userLocation) return true; - return distanceKm == null ? false : distanceKm <= radiusKm; - }) - .sort((a, b) => { - if (nQuery && a.queryScore !== b.queryScore) return b.queryScore - a.queryScore; - if (userLocation && a.distanceKm != null && b.distanceKm != null && a.distanceKm !== b.distanceKm) { - return a.distanceKm - b.distanceKm; - } - return a.point.name.localeCompare(b.point.name); - }); - }, [loadMode, pointMeta, query, radiusKm, tripId, userLocation]); - - const filteredPoints = useMemo(() => { - return nearbyPointMeta - .filter(({ point }) => (category === 'all' ? true : point.type === category)) - .filter(({ point }) => (subtype === 'all' ? true : point.subtype === subtype)) - .map(({ point }) => point); - }, [category, nearbyPointMeta, subtype]); - - const pointDistanceMap = useMemo(() => { - const next = new Map(); - nearbyPointMeta.forEach(({ point, distanceKm }) => { - if (distanceKm != null) next.set(point.id, distanceKm); - }); - return next; - }, [nearbyPointMeta]); - - const nearbyCardPoints = useMemo(() => { - const withUsefulImage = filteredPoints.filter((point) => Boolean((point as any).imageUrl || (point as any).photoUrl)); - return (withUsefulImage.length > 0 ? withUsefulImage : filteredPoints).slice(0, 8); - }, [filteredPoints]); - - // ── Trip logic ──────────────────────────────────────────────────────── - const tripStops = useMemo(() => buildTripStops(trip, points), [points, trip]); - const tripVisitedStopIds = useMemo( - () => new Set(Array.isArray(trip?.progress?.visitedStopIds) ? trip.progress.visitedStopIds.map(String) : []), - [trip?.progress?.visitedStopIds] - ); - const tripDays = useMemo(() => Array.from(new Set(tripStops.map((s) => s.dayNumber))).sort((a, b) => a - b), [tripStops]); - - useEffect(() => { - if (!tripId || tripDays.length === 0) { setActiveDay(null); return; } - if (activeDay == null || !tripDays.includes(activeDay)) setActiveDay(tripDays[0]); - }, [activeDay, tripDays, tripId]); - - const visibleTripStops = useMemo(() => { - if (!tripId) return []; - if (activeDay == null) return tripStops; - return tripStops.filter((s) => s.dayNumber === activeDay); - }, [activeDay, tripId, tripStops]); - - useEffect(() => { - if (!tripId) return; - const firstStopId = visibleTripStops[0]?.id ?? null; - if (!firstStopId) { - setSelectedId(null); - return; - } - if (!selectedId || !visibleTripStops.some((stop) => stop.id === selectedId)) { - setSelectedId(firstStopId); - } - }, [selectedId, tripId, visibleTripStops]); - - const tripVisitedCount = useMemo( - () => tripStops.filter((stop) => tripVisitedStopIds.has(stop.id)).length, - [tripStops, tripVisitedStopIds] - ); - const visibleVisitedCount = useMemo( - () => visibleTripStops.filter((stop) => tripVisitedStopIds.has(stop.id)).length, - [tripVisitedStopIds, visibleTripStops] - ); - - const completedRouteCoordinates = useMemo(() => { - if (!tripId || visibleTripStops.length < 2) return undefined; - - let contiguousVisitedUntil = -1; - for (let index = 0; index < visibleTripStops.length; index += 1) { - if (!tripVisitedStopIds.has(visibleTripStops[index].id)) break; - contiguousVisitedUntil = index; - } - - if (contiguousVisitedUntil < 1) return undefined; - return visibleTripStops.slice(0, contiguousVisitedUntil + 1).map((stop) => ({ - latitude: stop.point.lat, - longitude: stop.point.lng, - })); - }, [tripId, tripVisitedStopIds, visibleTripStops]); - - // ── List items ──────────────────────────────────────────────────────── - /* const listItems = useMemo(() => { - if (tripId) { - return visibleTripStops.map((s) => ({ - id: s.id, - title: s.title, - subtitle: `${tt('common.dayShort', 'Day')} ${s.dayNumber} • ${s.city}`, - meta: s.time || tt('explore.tripStopMeta', 'Trip stop'), - point: s.point, - dayNumber: s.dayNumber, - time: s.time, - })); - } - - return filteredPoints.map((p) => ({ - id: p.id, - title: p.name, - subtitle: p.city, - meta: CATEGORY_META[p.type]?.label || p.type, - point: p, - })); - }, [filteredPoints, tripId, tt, visibleTripStops]); - - useEffect(() => { - const firstId = listItems[0]?.id ?? null; - if (!firstId) { setSelectedId(null); return; } - if (!listItems.some((i) => i.id === selectedId)) setSelectedId(firstId); - }, [listItems, selectedId]); */ - - // ── Map data ────────────────────────────────────────────────────────── - const mapPoints = useMemo(() => { - if (tripId) return visibleTripStops.map((s) => s.point); - return filteredPoints; - }, [filteredPoints, tripId, visibleTripStops]); - - const routePreviewPoint = useMemo(() => { - if (!routePreviewPointId || tripId) return null; - return filteredPoints.find((point) => point.id === routePreviewPointId) || null; - }, [filteredPoints, routePreviewPointId, tripId]); - - const routeCoordinates = useMemo(() => { - if (!tripId) { - if (!userLocation || !routePreviewPoint) return undefined; - return [ - userLocation, - { latitude: routePreviewPoint.lat, longitude: routePreviewPoint.lng }, - ]; - } - return visibleTripStops.map((s) => ({ latitude: s.point.lat, longitude: s.point.lng })); - }, [routePreviewPoint, tripId, userLocation, visibleTripStops]); - - const mapMarkers = useMemo(() => { - if (tripId) { - return visibleTripStops.map((s, index) => ({ - id: s.id, - title: s.point.name, - subtitle: `${tt('common.dayShort', 'Day')} ${s.dayNumber}${s.time ? ` • ${s.time}` : ''}`, - coordinate: { latitude: s.point.lat, longitude: s.point.lng }, - color: tripVisitedStopIds.has(s.id) ? '#16A34A' : colors.primary, - iconName: tripVisitedStopIds.has(s.id) ? 'checkmark' : 'navigate', - badgeLabel: String(index + 1), - active: s.id === selectedId, - onPress: () => { setSelectedId(s.id); setActiveDay(s.dayNumber); }, - })); - } - - return filteredPoints.map((p) => ({ - id: p.id, - title: p.name, - subtitle: p.city, - coordinate: { latitude: p.lat, longitude: p.lng }, - color: getTypeColor(p.type, colors), - iconName: getYandexPoiIconName(p), - active: p.id === selectedId, - onPress: () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setSelectedId(p.id); - setPreviewPointId(p.id); - }, - })); - }, [colors, filteredPoints, selectedId, tripId, tripVisitedStopIds, tt, visibleTripStops]); - - useEffect(() => { - if (selectedId && !mapMarkers.some((marker) => marker.id === selectedId)) { - setSelectedId(null); - } - }, [mapMarkers, selectedId]); - - const selectedTripStop = useMemo(() => { - if (!tripId || !selectedId) return null; - return ( - visibleTripStops.find((stop) => stop.id === selectedId) || - tripStops.find((stop) => stop.id === selectedId) || - null - ); - }, [selectedId, tripId, tripStops, visibleTripStops]); - const selectedTripStopVisited = selectedTripStop ? tripVisitedStopIds.has(selectedTripStop.id) : false; - - const previewPoint = useMemo(() => { - if (!previewPointId || tripId) return null; - return filteredPoints.find((point) => point.id === previewPointId) || null; - }, [filteredPoints, previewPointId, tripId]); - - const previewDistanceKm = useMemo(() => { - if (!previewPoint) return null; - return pointDistanceMap.get(previewPoint.id) ?? null; - }, [pointDistanceMap, previewPoint]); - const previewSourceLabel = useMemo(() => formatSourceLabel(previewPoint?.source), [previewPoint?.source]); - const previewConfidence = useMemo(() => formatConfidence(previewPoint?.confidenceScore), [previewPoint?.confidenceScore]); - const previewLastUpdated = useMemo(() => formatLastUpdated(previewPoint?.lastVerifiedAt), [previewPoint?.lastVerifiedAt]); - const previewIsDetailed = Boolean(previewPoint && detailPointId === previewPoint.id); - - const initialRegion = useMemo( - () => (tripId ? buildRegion(mapPoints, null) : DEFAULT_REGION), - [mapPoints, tripId] - ); - const mapEnabled = useMemo(() => Platform.OS !== 'web', []); - const mapDisabledReason = mapEnabled ? undefined : tt('explore.mapDisabledWeb', 'Map is available in the Android app.'); - const tripModeSubtitle = useMemo(() => { - if (!tripId) return ''; - if (tripLoading) return tt('explore.tripLoadingSub', 'Loading...'); - const total = activeDay == null ? tripStops.length : visibleTripStops.length; - const visited = activeDay == null ? tripVisitedCount : visibleVisitedCount; - return tt('explore.tripProgressShort', '{{visited}}/{{total}} visited stops', { - visited, - total, - }); - }, [activeDay, tripId, tripLoading, tripStops.length, tripVisitedCount, tt, visibleTripStops.length, visibleVisitedCount]); - const scopeHintText = useMemo(() => { - if (tripId) return ''; - if (normalizeText(query).length >= 2 && yandexSearchRefreshing) { - return tt('explore.searchRefreshing', 'Yandex qidiruv natijalari yuklanmoqda...'); - } - if (normalizeText(query).length >= 2) { - return tt('explore.searchHint', 'Qidiruv natijalari Yandex va saqlangan joylardan yig‘ilmoqda.'); - } - if (category === 'transport' && yandexTransportRefreshing) { - return tt('explore.transportRefreshing', 'Yandex transport nuqtalari yuklanmoqda...'); - } - if (category !== 'transport' && yandexRuntimeRefreshing) { - return tt('explore.yandexPlacesRefreshing', 'Yandex joy malumotlari yuklanmoqda...'); - } - if (loadMode === 'radius') { - return refreshingPlaces - ? tt('explore.radiusRefreshing', 'Updating nearby places...') - : tt('explore.radiusHint', 'Only places within your chosen radius are shown.'); - } - if (!mapViewport) { - return tt('explore.viewportPreparing', 'Preparing the visible area...'); - } - if (!viewportRequest) { - return tt('explore.viewportZoomHint', 'Zoom in to load places in the visible area.'); - } - return refreshingPlaces - ? tt('explore.viewportRefreshing', 'Loading places in the visible area...') - : tt('explore.viewportHint', 'Places load only for the currently visible zone.'); - }, [category, loadMode, mapViewport, query, refreshingPlaces, tripId, tt, viewportRequest, yandexRuntimeRefreshing, yandexSearchRefreshing, yandexTransportRefreshing]); - - const scopeButtonLabel = useMemo(() => { - if (loadMode === 'radius') return `${radiusKm} km`; - return tt('explore.scopeVisibleArea', 'Visible area'); - }, [loadMode, radiusKm, tt]); - - // ── Actions ─────────────────────────────────────────────────────────── - const handleUseMyLocation = useCallback(async () => { - const granted = await ensureUserLocation(); - if (!granted) { - Alert.alert( - tt('explore.locationNeededTitle', 'Location access is required'), - tt('explore.locationNeededMsg', 'Allow location access to show nearby places.') - ); - return; - } - lastPlacesRequestKeyRef.current = null; - setLoadMode('radius'); - setRecenterToUserLocationSignal((value) => value + 1); - }, [ensureUserLocation, tt]); - - const handleAdjustRadius = useCallback((direction: 'decrease' | 'increase') => { - const nextIndex = direction === 'decrease' - ? Math.max(0, radiusIndex - 1) - : Math.min(RADIUS_OPTIONS.length - 1, radiusIndex + 1); - if (nextIndex === radiusIndex) return; - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setRadiusKm(RADIUS_OPTIONS[nextIndex]); - resetExploreSelection(); - }, [radiusIndex, resetExploreSelection]); - - const handleSetLoadMode = useCallback((nextMode: ExploreLoadMode) => { - if (nextMode === loadMode) return; - if (nextMode === 'radius' && !userLocation) { - void handleUseMyLocation(); - return; - } - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - lastPlacesRequestKeyRef.current = null; - setLoadMode(nextMode); - resetExploreSelection(); - }, [handleUseMyLocation, loadMode, resetExploreSelection, userLocation]); - - const handleToggleScopePanel = useCallback(() => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setScopePanelOpen((current) => !current); - }, []); - - const handleViewportChanged = useCallback((nextViewport: ExploreMapViewport) => { - setMapViewport(nextViewport); - }, []); - - const handleOpenYandexDirections = useCallback(async (point: MapPoint) => { - const routeMode = point.type === 'transport' ? 'mt' : 'auto'; - const text = encodeURIComponent(point.name || 'TravelorAI place'); - const yandexPointWeb = `https://yandex.com/maps/?ll=${point.lng},${point.lat}&z=16&pt=${point.lng},${point.lat}&text=${text}`; - const yandexRouteWeb = userLocation - ? `https://yandex.com/maps/?rtext=${userLocation.latitude},${userLocation.longitude}~${point.lat},${point.lng}&rtt=${routeMode}` - : yandexPointWeb; - const yandexPointApp = `yandexmaps://maps.yandex.com/?ll=${point.lng},${point.lat}&z=16&pt=${point.lng},${point.lat}&text=${text}`; - const yandexRouteApp = userLocation - ? `yandexmaps://maps.yandex.com/?rtext=${userLocation.latitude},${userLocation.longitude}~${point.lat},${point.lng}&rtt=${routeMode}` - : yandexPointApp; - const fallbackUrls = - Platform.OS === 'android' - ? [yandexRouteApp, yandexRouteWeb] - : [yandexRouteWeb]; - - try { - for (const candidate of fallbackUrls) { - if (candidate.startsWith('https://')) { - await Linking.openURL(candidate); - return; - } - - if (await Linking.canOpenURL(candidate)) { - await Linking.openURL(candidate); - return; - } - } - - Alert.alert(tt('explore.directionErrorTitle', 'Cannot open maps'), yandexRouteWeb); - } catch { - Alert.alert(tt('explore.directionErrorTitle', 'Cannot open maps'), tt('explore.directionErrorMsg', 'Failed to open directions.')); - } - }, [tt, userLocation]); - - const handleOpenDirections = useCallback(async (point: MapPoint) => { - if (tripId) { - await handleOpenYandexDirections(point); - return; - } - - let origin = userLocation; - - if (!origin) { - const granted = await ensureUserLocation(); - if (!granted) { - Alert.alert( - tt('explore.locationNeededTitle', 'Location access is required'), - tt('explore.locationNeededMsg', 'Allow location access to draw route inside the map.') - ); - return; - } - - try { - const current = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); - origin = { latitude: current.coords.latitude, longitude: current.coords.longitude }; - setUserLocation(origin); - } catch { - Alert.alert(tt('explore.locationErrorTitle', 'Location unavailable'), tt('explore.locationErrorMsg', 'Could not get your location.')); - return; - } - } - - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setRoutePreviewPointId(point.id); - setPreviewPointId(point.id); - setDetailPointId(point.id); - setFocusedMapId(point.id); - setSelectedId(point.id); - }, [ensureUserLocation, handleOpenYandexDirections, tripId, tt, userLocation]); - - const handleOpenDetail = useCallback((point: MapPoint) => { - router.push({ - pathname: '/place/[slug]', - params: { - slug: point.slug || point.id, - id: point.id, - name: point.name, - city: point.city, - type: point.type, - subtype: point.subtype || '', - lat: String(point.lat), - lng: String(point.lng), - info: point.info || '', - description: point.description || point.info || '', - imageUrl: point.imageUrl || '', - gallery: JSON.stringify(point.gallery || []), - price: point.price != null ? String(point.price) : '', - priceLevel: point.priceLevel != null ? String(point.priceLevel) : '', - rating: point.rating != null ? String(point.rating) : '', - ratingCount: point.ratingCount != null ? String(point.ratingCount) : '', - phone: point.phone || '', - website: point.website || '', - openingHours: JSON.stringify(point.openingHours || []), - source: point.source || '', - sourceUrl: point.sourceUrl || '', - lastVerifiedAt: point.lastVerifiedAt || '', - confidenceScore: point.confidenceScore != null ? String(point.confidenceScore) : '', - distanceKm: point.distanceKm != null ? String(point.distanceKm) : '', - icon: point.icon || 'pin', - }, - }); - }, []); - - const handleToggleWishlist = useCallback(async (point: MapPoint) => { - if (!userId) { - Alert.alert(tt('explore.authNeededTitle', 'Sign in required'), tt('explore.authNeededMsg', 'Please sign in to save places.')); - return; - } - await toggleWishlist({ id: point.id, poiId: point.id, name: point.name, city: point.city, slug: point.slug, type: point.type, icon: point.icon }); - }, [toggleWishlist, tt, userId]); - - const handleExitTripMode = useCallback(() => { - router.replace('/(tabs)/explore' as any); - }, []); - - const persistVisitedStops = useCallback( - async (nextVisitedIds: string[]) => { - if (!tripId || !trip) return; - - const now = new Date().toISOString(); - const uniqueVisited = Array.from(new Set(nextVisitedIds.map(String))); - const nextTrip: TripPlan = { - ...trip, - updatedAt: now, - progress: { - visitedStopIds: uniqueVisited, - notes: - trip.progress?.notes && typeof trip.progress.notes === 'object' && !Array.isArray(trip.progress.notes) - ? trip.progress.notes - : {}, - updatedAt: now, - }, - }; - - setTrip(nextTrip); - - try { - const cachedTrips = await getJSON(tripStorageKey); - if (Array.isArray(cachedTrips)) { - const updatedTrips = cachedTrips.map((item) => - String(item?.id) === String(tripId) ? { ...item, ...nextTrip } : item - ); - await saveJSON(tripStorageKey, updatedTrips); - } - } catch { - // ignore local cache write failure - } - - try { - const token = await getItem(KEYS.TOKEN); - if (token) { - await tripsAPI.update(String(tripId), nextTrip); - } - } catch { - // keep local progress even if server sync fails - } - }, - [trip, tripId, tripStorageKey] - ); - - const handleToggleTripStopVisited = useCallback( - async (stopId: string) => { - if (!tripId || !trip) return; - setSavingStopId(stopId); - try { - const nextSet = new Set(Array.isArray(trip.progress?.visitedStopIds) ? trip.progress.visitedStopIds.map(String) : []); - if (nextSet.has(stopId)) nextSet.delete(stopId); - else nextSet.add(stopId); - await persistVisitedStops(Array.from(nextSet)); - } finally { - setSavingStopId((current) => (current === stopId ? null : current)); - } - }, - [persistVisitedStops, trip, tripId] - ); - - const handleClosePreview = useCallback(() => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - }, []); - - useEffect(() => { - if (previewPointId && !filteredPoints.some((point) => point.id === previewPointId)) { - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - } - }, [filteredPoints, previewPointId]); - - // ── List item renderer ──────────────────────────────────────────────── - const renderListItem = ({ item, index }: { item: ExploreListItem; index: number }) => { - const wished = isWishlisted(item.point.id) || isWishlisted(item.point.slug); - const isFocused = focusedMapId === item.id; - const iconColor = getTypeColor(item.point.type, colors); - - const handleMapBtn = () => { - if (isFocused) { - handleOpenDirections(item.point); - } else { - setFocusedMapId(item.id); - setSelectedId(item.id); - } - }; - - return ( - - handleOpenDetail(item.point)}> - - - - - - {/* Name + city */} - - {item.title} - - - {item.subtitle} - - - - {/* Wishlist */} - handleToggleWishlist(item.point)} - style={styles.actionCircle} - wishedStyle={styles.actionCircleWished} - size={17} - defaultColor={colors.textSecondary} - activeColor="#EF4444" - /> - - {/* Map focus → directions */} - - - - - - - ); - }; - - // ── Header (rendered as FlatList header) ───────────────────────────── - const header = ( - - - {/* Title row */} - - {tt('tabs.explore', 'Explore')} - - {/* Location / radius buttons */} - - {!tripId ? ( - - - - {scopeButtonLabel} - - - - ) : null} - - - {locating - ? - : - } - - {userLocation ? tt('explore.nearMeOn', 'Near me') : tt('explore.nearMe', 'Near me')} - - - - - - {/* Trip mode banner */} - {tripId ? ( - - - - {trip?.title || tt('explore.tripLoadingTitle', 'Trip route')} - - - {tripModeSubtitle} - - - - {tt('common.exit', 'Exit')} - - - ) : null} - - {/* Map */} - setFocusedMapId(null)} - routeCoordinates={routeCoordinates} - completedRouteCoordinates={completedRouteCoordinates} - userLocation={userLocation} - enabled={mapEnabled} - disabledReason={mapDisabledReason} - onViewportChanged={handleViewportChanged} - /> - - {!tripId ? ( - <> - {/* Search */} - - - { - setQuery(v); - setListVersion((n) => n + 1); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - setFocusedMapId(null); - setSelectedId(null); - }} - placeholder={tt('explore.searchPlaceholder', 'Search places, cities...')} - placeholderTextColor={colors.textMuted} - style={styles.searchInput} - autoCapitalize="none" - autoCorrect={false} - /> - {query.length > 0 ? ( - { - setQuery(''); - setListVersion((n) => n + 1); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - setFocusedMapId(null); - setSelectedId(null); - }}> - - - ) : null} - - - {/* Category blocks */} - - {(Object.keys(CATEGORY_META) as POIType[]).map((key) => { - const meta = CATEGORY_META[key]; - const active = category === key; - const count = points.filter((p) => p.type === key).length; - - return ( - handleSetCategory(active ? 'all' : key)} - style={styles.catBlock} - activeStyle={[styles.catBlockActive, { borderColor: meta.markerColor + '55', backgroundColor: meta.markerColor + '12' }]} - > - - - - {tt(`explore.cat.${key}`, meta.label)} - - {count} - - - ); - })} - - - {/* Sub-category chips */} - - {visibleSubcategories.length > 0 ? ( - - {visibleSubcategories.map((item) => { - const chipActive = subtype === item.key; - return ( - handleSetSubtype(chipActive ? 'all' : (item.key as SubtypeFilter))} - activeOpacity={0.82} - > - - {item.label} - - ); - })} - - ) : null} - - - ) : null} - - {/* Trip day selector */} - {tripId && tripDays.length > 0 ? ( - - {tripDays.map((d) => ( - setActiveDay(d)} - activeOpacity={0.82} - > - - {tt('common.dayShort', 'Day')} {d} - - - ))} - - ) : null} - - {/* Section header */} - - - {tripId ? tt('explore.tripStopsTitle', 'Route stops') : tt('explore.listTitle', 'Places')} - - - {tripId ? visibleTripStops.length : filteredPoints.length} - - - - {/* Status / error */} - {tripLoading && tripId ? ( - - - {tt('explore.tripLoadingSub', 'Loading trip stops...')} - - ) : null} - - {error ? ( - - {tt('explore.loadErrorTitle', 'Could not load places')} - {error} - loadPoints({ force: true })} activeOpacity={0.82}> - {tt('common.retry', 'Retry')} - - - ) : null} - - ); - - // ── Root render ─────────────────────────────────────────────────────── - void renderListItem; - void header; - - if (loadMode === 'radius' && locating && !userLocation) { - return ( - - - {tt('explore.locationLoadingTitle', 'Getting your location...')} - - {tt('explore.locationLoadingText', 'Explore opens after your current location is ready.')} - - - ); - } - - if (loadMode === 'radius' && (!userLocation || locationAccess === 'denied' || locationAccess === 'error')) { - return ( - - - - - - {tt('explore.locationNeededTitle', 'Location access is required')} - - {locationError || tt('explore.locationNeededMsg', 'Allow location access first, then Explore will open and keep your position visible on the map.')} - - - {tt('explore.allowLocation', 'Allow access')} - - Linking.openSettings()} activeOpacity={0.82}> - {tt('explore.openSettings', 'Open settings')} - - - - ); - } - - if (loading && points.length === 0 && loadMode !== 'viewport') { - return ( - - - {tt('explore.loading', 'Loading places...')} - - ); - } - - return ( - - setFocusedMapId(null)} - routeCoordinates={routeCoordinates} - completedRouteCoordinates={completedRouteCoordinates} - userLocation={userLocation} - enabled={mapEnabled} - disabledReason={mapDisabledReason} - onViewportChanged={handleViewportChanged} - /> - - - - {tripId ? ( - - - - {trip?.title || tt('explore.tripLoadingTitle', 'Trip route')} - - - {tripModeSubtitle} - - - - {tt('common.exit', 'Exit')} - - - ) : ( - - - router.push('/side-menu' as any)} activeOpacity={0.82}> - - - TravelorAI - router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> - - - - - - { - setQuery(value); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - setFocusedMapId(null); - setSelectedId(null); - }} - placeholder={tt('explore.searchPlaceholder', 'Search places, cities...')} - placeholderTextColor={colors.textMuted} - style={styles.searchInput} - autoCapitalize="none" - autoCorrect={false} - /> - {query.length > 0 ? ( - { - setQuery(''); - setPreviewPointId(null); - setDetailPointId(null); - setRoutePreviewPointId(null); - setFocusedMapId(null); - setSelectedId(null); - }}> - - - ) : null} - router.push('/search-filters' as any)} - activeOpacity={0.82} - > - - - - - handleSetCategory('all')} activeOpacity={0.82}> - Barchasi - - {(['restaurant', 'hotel', 'landmark', 'transport'] as POIType[]).map((key) => { - const active = category === key; - const meta = CATEGORY_META[key]; - return ( - handleSetCategory(active ? 'all' : key)} activeOpacity={0.82}> - - {tt(`explore.cat.${key}`, meta.label)} - - ); - })} - - - )} - - {tripLoading && tripId ? ( - - - {tt('explore.tripLoadingSub', 'Loading trip stops...')} - - ) : null} - - {error ? ( - - {tt('explore.loadErrorTitle', 'Could not load places')} - {error} - loadPoints({ force: true })} activeOpacity={0.82}> - {tt('common.retry', 'Retry')} - - - ) : null} - - {!tripId && scopePanelOpen ? ( - - - - handleSetLoadMode('radius')} - activeOpacity={0.82} - > - - - {tt('explore.scopeRadius', 'Radius')} - - - - handleSetLoadMode('viewport')} - activeOpacity={0.82} - > - - - {tt('explore.scopeVisibleArea', 'Visible area')} - - - - - - - - {loadMode === 'radius' - ? tt('explore.scopeNearbyTitle', 'Nearby places') - : tt('explore.scopeVisibleTitle', 'Places on this map area')} - - {scopeHintText} - - - {loadMode === 'radius' ? ( - - handleAdjustRadius('decrease')} - activeOpacity={0.82} - disabled={!canDecreaseRadius} - > - - - - {radiusKm} km - - handleAdjustRadius('increase')} - activeOpacity={0.82} - disabled={!canIncreaseRadius} - > - - - - ) : ( - - {refreshingPlaces ? ( - - ) : ( - - )} - - )} - - - - ) : null} - - - {tripId && tripDays.length > 0 ? ( - - - setActiveDay(null)} - activeOpacity={0.82} - > - - {tt('common.all', 'All')} - - - {tripDays.map((d) => ( - setActiveDay(d)} - activeOpacity={0.82} - > - - {tt('common.dayShort', 'Day')} {d} - - - ))} - - - - - - {visibleVisitedCount}/{visibleTripStops.length} {tt('explore.tripVisitedShort', 'visited')} - - - - {selectedTripStop ? ( - - - - - {visibleTripStops.findIndex((stop) => stop.id === selectedTripStop.id) + 1} - - - - {selectedTripStop.title} - - {tt('common.dayShort', 'Day')} {selectedTripStop.dayNumber} - {selectedTripStop.time ? ` • ${selectedTripStop.time}` : ''} - {selectedTripStop.city ? ` • ${selectedTripStop.city}` : ''} - - - - - handleToggleTripStopVisited(selectedTripStop.id)} - activeOpacity={0.82} - disabled={savingStopId === selectedTripStop.id} - > - {savingStopId === selectedTripStop.id ? ( - - ) : ( - - )} - - {selectedTripStopVisited ? tt('explore.tripVisitedUndo', 'Mark as not visited') : tt('explore.tripVisitedMark', 'Mark as visited')} - - - - handleOpenDirections(selectedTripStop.point)} - activeOpacity={0.82} - > - - {tt('explore.directions', 'Directions')} - - - - ) : null} - - ) : !tripId ? ( - - {previewPoint ? ( - - - - - - - - {previewPoint.name} - {previewPoint.city} - - - - {tt(`explore.cat.${previewPoint.type}`, CATEGORY_META[previewPoint.type]?.label || previewPoint.type)} - - - {previewDistanceKm != null ? ( - - {previewDistanceKm.toFixed(1)} km - - ) : null} - {previewSourceLabel ? ( - - {previewSourceLabel} - - ) : null} - {previewConfidence ? ( - - Ishonch {previewConfidence} - - ) : null} - {previewLastUpdated ? ( - - Yangilandi {previewLastUpdated} - - ) : null} - - - - - - - - - - {previewPoint.info || tt('explore.detailErrorMsg', 'This place has no detail page yet.')} - - - {previewIsDetailed ? ( - - - - - {routePreviewPointId === previewPoint.id - ? tt('explore.routePreviewOn', 'Route preview is shown on the map.') - : tt('explore.routePreviewHint', 'Directions can be previewed inside this map.')} - - - - - - {previewConfidence - ? `${tt('explore.confidence', 'Confidence')}: ${previewConfidence}` - : tt('explore.confidenceUnknown', 'Confidence score is not available yet.')} - - - - - - {previewLastUpdated - ? `${tt('explore.lastUpdated', 'Last updated')}: ${previewLastUpdated}` - : tt('explore.lastUpdatedUnknown', 'Last verified date is not available yet.')} - - - - - - {previewPoint.lat.toFixed(5)}, {previewPoint.lng.toFixed(5)} - - - {previewSourceLabel ? ( - - - {`${tt('explore.source', 'Source')}: ${previewSourceLabel}`} - - ) : null} - - ) : null} - - - handleOpenDirections(previewPoint)} - activeOpacity={0.82} - > - - - {tt('explore.routeInApp', 'Route')} - - - - handleOpenDetail(previewPoint)} - activeOpacity={0.82} - > - - {tt('explore.moreDetails', 'More details')} - - - - - {previewIsDetailed ? ( - handleOpenYandexDirections(previewPoint)} - activeOpacity={0.82} - > - - - {tt('explore.openInYandex', 'Open exact navigation in Yandex')} - - - ) : null} - - ) : ( - <> - - {visibleSubcategories.length > 0 ? ( - - {visibleSubcategories.map((item) => { - const chipActive = subtype === item.key; - return ( - handleSetSubtype(chipActive ? 'all' : (item.key as SubtypeFilter))} - activeOpacity={0.82} - > - - {item.label} - - ); - })} - - ) : null} - - - - - Yaqin atrofdagi joylar - {filteredPoints.length} - - - {nearbyCardPoints.map((point) => { - const distance = pointDistanceMap.get(point.id); - return ( - handleOpenDetail(point)} activeOpacity={0.9}> - - - handleToggleWishlist(point)} activeOpacity={0.82}> - - - - - - {point.name} - {point.rating ? ( - ★ {Number(point.rating).toFixed(1)} - ) : null} - - - {tt(`explore.cat.${point.type}`, CATEGORY_META[point.type]?.label || point.type)} - {distance != null ? ` · ${distance.toFixed(1)} km` : ''} - - - - ); - })} - - - - )} - - ) : null} - - - ); -} - -// ─── Styles ─────────────────────────────────────────────────────────────────── - -function createStyles(colors: AppColors) { - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - mapOverlay: { - ...StyleSheet.absoluteFillObject, - }, - topOverlay: { - paddingHorizontal: SPACING.lg, - gap: SPACING.sm, - paddingTop: SPACING.sm, - }, - exploreChrome: { - gap: SPACING.sm, - }, - exploreHeader: { - height: 34, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerIconBtn: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: colors.glassStrong, - alignItems: 'center', - justifyContent: 'center', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.12, - shadowRadius: 16, - elevation: 3, - }, - headerBrand: { - fontFamily: FONTS.display, - fontSize: 15, - color: colors.text, - textShadowColor: colors.surface + 'AA', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 4, - }, - scopePopover: { - marginTop: SPACING.xs, - }, - bottomOverlay: { - position: 'absolute', - left: 0, - right: 0, - paddingHorizontal: SPACING.lg, - gap: SPACING.sm, - }, - centered: { - alignItems: 'center', - justifyContent: 'center', - gap: SPACING.sm, - }, - loadingText: { - fontFamily: FONTS.medium, - fontSize: 14, - color: colors.textSecondary, - }, - permissionGate: { - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SPACING.lg, - gap: SPACING.md, - }, - permissionCard: { - width: '100%', - maxWidth: 360, - borderRadius: RADIUS.xl, - backgroundColor: colors.surface, - padding: SPACING.lg, - alignItems: 'center', - gap: SPACING.md, - shadowColor: colors.shadow, - shadowOpacity: 0.12, - shadowRadius: 24, - shadowOffset: { width: 0, height: 12 }, - elevation: 5, - }, - permissionIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: colors.primaryPale, - alignItems: 'center', - justifyContent: 'center', - }, - permissionTitle: { - fontFamily: FONTS.semibold, - fontSize: 18, - color: colors.text, - textAlign: 'center', - }, - permissionText: { - fontFamily: FONTS.regular, - fontSize: 13, - lineHeight: 20, - color: colors.textSecondary, - textAlign: 'center', - }, - permissionPrimaryBtn: { - width: '100%', - height: 44, - borderRadius: RADIUS.full, - backgroundColor: colors.primary, - alignItems: 'center', - justifyContent: 'center', - }, - permissionPrimaryBtnText: { - fontFamily: FONTS.semibold, - fontSize: 13, - color: colors.textInverse, - }, - permissionSecondaryBtn: { - width: '100%', - height: 42, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.background, - alignItems: 'center', - justifyContent: 'center', - }, - permissionSecondaryBtnText: { - fontFamily: FONTS.medium, - fontSize: 13, - color: colors.textSecondary, - }, - - // ── Header ── - header: { - paddingHorizontal: SPACING.lg, - gap: SPACING.md, - paddingBottom: SPACING.md, - }, - titleRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - title: { - fontFamily: FONTS.display, - fontSize: 34, - color: '#FFFFFF', - textShadowColor: 'rgba(0,0,0,0.28)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 3, - }, - titleActions: { - flexDirection: 'row', - alignItems: 'center', - gap: SPACING.xs, - }, - pillBtn: { - height: 36, - borderRadius: RADIUS.md, - backgroundColor: colors.glassStrong, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - gap: 5, - shadowColor: colors.shadow, - shadowOpacity: 0.12, - shadowRadius: 18, - shadowOffset: { width: 0, height: 8 }, - elevation: 4, - }, - pillBtnActive: { - backgroundColor: colors.primary, - }, - pillBtnText: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.primary, - }, - pillBtnTextActive: { - color: colors.textInverse, - }, - scopeCard: { - borderRadius: RADIUS.xl, - backgroundColor: colors.glassStrong, - padding: SPACING.md, - gap: SPACING.sm, - shadowColor: colors.shadow, - shadowOpacity: 0.14, - shadowRadius: 24, - shadowOffset: { width: 0, height: 12 }, - elevation: 9, - }, - scopeModeRow: { - flexDirection: 'row', - gap: SPACING.xs, - }, - scopeModeBtn: { - flex: 1, - height: 34, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.borderLight, - backgroundColor: colors.background, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - }, - scopeModeBtnActive: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - scopeModeText: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.textSecondary, - }, - scopeModeTextActive: { - color: colors.textInverse, - }, - scopeBodyRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: SPACING.sm, - }, - scopeInfoWrap: { - flex: 1, - gap: 2, - }, - scopeTitle: { - fontFamily: FONTS.semibold, - fontSize: 13, - color: colors.text, - }, - scopeHint: { - fontFamily: FONTS.regular, - fontSize: 11, - lineHeight: 16, - color: colors.textSecondary, - }, - radiusStepper: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.borderLight, - backgroundColor: colors.background, - paddingHorizontal: 6, - paddingVertical: 4, - }, - radiusStepBtn: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.surface, - }, - radiusStepBtnDisabled: { - opacity: 0.45, - }, - radiusValue: { - minWidth: 60, - textAlign: 'center', - fontFamily: FONTS.semibold, - fontSize: 12, - color: colors.text, - }, - scopeStatusBadge: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primaryPale, - borderWidth: 1, - borderColor: colors.primary + '22', - }, - - // ── Trip banner ── - tripBanner: { - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.primary, - backgroundColor: colors.primaryPale, - paddingHorizontal: SPACING.md, - paddingVertical: SPACING.md, - flexDirection: 'row', - alignItems: 'center', - gap: SPACING.md, - }, - tripBannerTitle: { - fontFamily: FONTS.semibold, - fontSize: 15, - color: colors.text, - }, - tripBannerSub: { - fontFamily: FONTS.regular, - fontSize: 12, - color: colors.textSecondary, - }, - tripExitBtn: { - height: 36, - borderRadius: RADIUS.full, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.primary, - paddingHorizontal: SPACING.md, - alignItems: 'center', - justifyContent: 'center', - }, - tripExitBtnText: { - fontFamily: FONTS.semibold, - fontSize: 12, - color: colors.primary, - }, - - // ── Search ── - searchBox: { - height: 52, - borderRadius: RADIUS.full, - backgroundColor: colors.glassStrong, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: SPACING.md, - gap: 8, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.1, - shadowRadius: 22, - elevation: 4, - }, - searchInput: { - flex: 1, - paddingVertical: 0, - color: colors.text, - fontFamily: FONTS.regular, - fontSize: 14, - }, - searchTuneBtn: { - width: 34, - height: 34, - borderRadius: 17, - backgroundColor: colors.successPale, - alignItems: 'center', - justifyContent: 'center', - marginRight: -4, - }, - searchTuneBtnActive: { - backgroundColor: colors.success, - }, - topChipRow: { - gap: SPACING.sm, - paddingRight: SPACING.lg, - paddingBottom: 2, - }, - topChip: { - height: 31, - borderRadius: RADIUS.full, - backgroundColor: colors.glassStrong, - paddingHorizontal: SPACING.md, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.08, - shadowRadius: 12, - elevation: 2, - }, - topChipActive: { - backgroundColor: colors.text, - }, - topChipText: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textSecondary, - }, - topChipTextActive: { - color: colors.textInverse, - }, - - // ── Category blocks ── - catRow: { - gap: SPACING.sm, - paddingRight: SPACING.md, - }, - catBlock: { - width: 88, - height: 122, - borderRadius: RADIUS.xl, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 10, - gap: 5, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.08, - shadowRadius: 20, - elevation: 3, - }, - catBlockActive: {}, - catIconCircle: { - width: 44, - height: 44, - borderRadius: RADIUS.md, - alignItems: 'center', - justifyContent: 'center', - }, - catLabel: { - fontFamily: FONTS.medium, - fontSize: 10, - color: colors.textSecondary, - textAlign: 'center', - }, - catBadge: { - minWidth: 22, - height: 15, - borderRadius: 8, - backgroundColor: colors.cardMuted, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 5, - }, - catBadgeText: { - fontFamily: FONTS.semibold, - fontSize: 9, - color: colors.textSecondary, - }, - catBadgeTextActive: { - color: '#fff', - }, - nearbyPanel: { - marginHorizontal: -SPACING.lg, - paddingLeft: SPACING.lg, - gap: SPACING.sm, - }, - nearbyHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingRight: SPACING.lg, - }, - nearbyTitle: { - fontFamily: FONTS.display, - fontSize: 18, - color: colors.text, - }, - nearbyCount: { - minWidth: 30, - height: 24, - borderRadius: 12, - backgroundColor: colors.glassStrong, - textAlign: 'center', - textAlignVertical: 'center', - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.success, - overflow: 'hidden', - }, - nearbyCardRow: { - gap: SPACING.sm, - paddingRight: SPACING.lg, - paddingBottom: SPACING.sm, - }, - nearbyCard: { - width: 172, - borderRadius: 20, - backgroundColor: colors.surface, - overflow: 'hidden', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.14, - shadowRadius: 22, - elevation: 5, - }, - nearbyImageWrap: { - height: 108, - backgroundColor: colors.cardMuted, - }, - nearbyImage: { - flex: 1, - }, - nearbyImageRadius: { - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - }, - nearbyImageOverlay: { - flex: 1, - justifyContent: 'flex-end', - alignItems: 'flex-start', - padding: 8, - backgroundColor: 'rgba(0,0,0,0.08)', - }, - nearbyImageSource: { - overflow: 'hidden', - borderRadius: RADIUS.full, - backgroundColor: 'rgba(255,255,255,0.86)', - paddingHorizontal: 8, - paddingVertical: 3, - fontFamily: FONTS.semibold, - fontSize: 9, - color: colors.text, - }, - nearbyImageFallback: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.successPale, - }, - nearbyHeart: { - position: 'absolute', - top: 8, - right: 8, - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: 'rgba(255,255,255,0.82)', - alignItems: 'center', - justifyContent: 'center', - }, - nearbyCardBody: { - paddingHorizontal: SPACING.sm, - paddingVertical: SPACING.sm, - gap: 4, - }, - nearbyNameRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - nearbyName: { - flex: 1, - fontFamily: FONTS.semibold, - fontSize: 13, - color: colors.text, - }, - nearbyRating: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.gold, - }, - nearbyMeta: { - fontFamily: FONTS.regular, - fontSize: 11, - color: colors.textSecondary, - }, - previewCard: { - borderRadius: RADIUS.xxl, - backgroundColor: colors.glassStrong, - padding: SPACING.lg, - gap: SPACING.sm, - shadowColor: colors.shadow, - shadowOpacity: 0.16, - shadowRadius: 28, - shadowOffset: { width: 0, height: 14 }, - elevation: 12, - }, - previewCardDetailed: { - gap: SPACING.md, - }, - previewHeader: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: SPACING.sm, - }, - previewIconWrap: { - width: 52, - height: 52, - borderRadius: RADIUS.lg, - alignItems: 'center', - justifyContent: 'center', - }, - previewBody: { - flex: 1, - gap: 4, - }, - previewName: { - fontFamily: FONTS.semibold, - fontSize: 16, - color: colors.text, - }, - previewCity: { - fontFamily: FONTS.regular, - fontSize: 13, - color: colors.textSecondary, - }, - previewMetaRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - flexWrap: 'wrap', - }, - previewMetaChip: { - height: 24, - borderRadius: RADIUS.full, - paddingHorizontal: 10, - backgroundColor: colors.cardMuted, - alignItems: 'center', - justifyContent: 'center', - }, - previewMetaChipText: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.textSecondary, - }, - previewMetaChipSource: { - backgroundColor: colors.primary + '14', - }, - previewMetaChipSourceText: { - color: colors.primary, - }, - previewCloseBtn: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.background, - }, - previewInfo: { - fontFamily: FONTS.regular, - fontSize: 12, - lineHeight: 18, - color: colors.textSecondary, - }, - detailGrid: { - gap: 8, - padding: SPACING.sm, - borderRadius: RADIUS.lg, - backgroundColor: colors.background, - borderWidth: 1, - borderColor: colors.borderLight, - }, - detailLine: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - detailText: { - flex: 1, - fontFamily: FONTS.medium, - fontSize: 12, - lineHeight: 17, - color: colors.textSecondary, - }, - previewActions: { - flexDirection: 'row', - gap: SPACING.sm, - }, - previewActionBtn: { - flex: 1, - height: 42, - borderRadius: RADIUS.full, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - }, - previewActionPrimary: { - backgroundColor: colors.primary, - }, - previewActionSecondary: { - backgroundColor: colors.primaryPale, - borderWidth: 1, - borderColor: colors.primary + '33', - }, - previewActionText: { - fontFamily: FONTS.semibold, - fontSize: 12, - }, - previewActionTextPrimary: { - fontFamily: FONTS.semibold, - fontSize: 12, - color: colors.textInverse, - }, - previewExternalBtn: { - height: 40, - borderRadius: RADIUS.full, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 7, - backgroundColor: colors.primaryPale, - borderWidth: 1, - borderColor: colors.primary + '22', - }, - previewExternalBtnText: { - fontFamily: FONTS.semibold, - fontSize: 12, - color: colors.primary, - }, - - // ── Sub-category chips ── - chipsRow: { - gap: SPACING.xs, - paddingRight: SPACING.md, - }, - chip: { - height: 32, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.borderLight, - backgroundColor: colors.surface, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - gap: 4, - }, - chipText: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.textSecondary, - }, - chipTextActive: { - color: '#fff', - }, - - // ── Day selector ── - dayRow: { - gap: SPACING.xs, - paddingRight: SPACING.md, - }, - dayChip: { - height: 36, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SPACING.md, - }, - dayChipActive: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - dayChipText: { - fontFamily: FONTS.medium, - fontSize: 12, - color: colors.textSecondary, - }, - dayChipTextActive: { - color: '#fff', - }, - - // ── Section header ── - tripProgressPill: { - alignSelf: 'flex-start', - height: 28, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.primary + '33', - backgroundColor: colors.surface, - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingHorizontal: 10, - }, - tripProgressPillText: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.textSecondary, - }, - tripStopCard: { - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - padding: SPACING.md, - gap: SPACING.sm, - shadowColor: '#000', - shadowOpacity: 0.14, - shadowRadius: 16, - shadowOffset: { width: 0, height: 8 }, - elevation: 10, - }, - tripStopHead: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: SPACING.sm, - }, - tripStopBadge: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primaryPale, - borderWidth: 1, - borderColor: colors.primary + '33', - }, - tripStopBadgeText: { - fontFamily: FONTS.semibold, - fontSize: 12, - color: colors.primary, - }, - tripStopTitle: { - fontFamily: FONTS.semibold, - fontSize: 15, - color: colors.text, - }, - tripStopMeta: { - marginTop: 2, - fontFamily: FONTS.regular, - fontSize: 12, - color: colors.textSecondary, - }, - tripStopActions: { - flexDirection: 'row', - gap: SPACING.sm, - }, - tripStopBtn: { - flex: 1, - height: 40, - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.primary + '33', - backgroundColor: colors.primaryPale, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingHorizontal: 10, - }, - tripStopBtnVisited: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - tripStopBtnText: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.primary, - }, - tripStopBtnTextVisited: { - color: colors.textInverse, - }, - tripStopBtnPrimary: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - tripStopBtnPrimaryText: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textInverse, - }, - sectionRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginTop: SPACING.xs, - }, - sectionTitle: { - fontFamily: FONTS.semibold, - fontSize: 18, - color: colors.text, - }, - sectionCount: { - fontFamily: FONTS.semibold, - fontSize: 13, - color: colors.primary, - }, - - // ── Status / error cards ── - statusCard: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderRadius: RADIUS.lg, - borderWidth: 1, - borderColor: colors.borderLight, - backgroundColor: colors.surface, - paddingHorizontal: SPACING.md, - paddingVertical: SPACING.sm, - }, - statusText: { - fontFamily: FONTS.medium, - fontSize: 13, - color: colors.textSecondary, - }, - errorCard: { - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.errorPale, - backgroundColor: colors.errorPale, - padding: SPACING.md, - gap: 8, - }, - errorTitle: { - fontFamily: FONTS.semibold, - fontSize: 14, - color: colors.error, - }, - errorText: { - fontFamily: FONTS.regular, - fontSize: 12, - lineHeight: 18, - color: colors.textSecondary, - }, - retryBtn: { - alignSelf: 'flex-start', - height: 36, - borderRadius: RADIUS.full, - backgroundColor: colors.error, - paddingHorizontal: SPACING.md, - alignItems: 'center', - justifyContent: 'center', - }, - retryBtnText: { - fontFamily: FONTS.semibold, - fontSize: 12, - color: '#fff', - }, - - // ── Place list item ── - placeRow: { - flexDirection: 'row', - alignItems: 'center', - gap: SPACING.sm, - marginHorizontal: SPACING.lg, - marginBottom: SPACING.sm, - paddingHorizontal: SPACING.md, - paddingVertical: SPACING.md, - borderRadius: RADIUS.lg, - backgroundColor: colors.surface, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.07, - shadowRadius: 18, - elevation: 2, - }, - placeIcon: { - width: 46, - height: 46, - borderRadius: RADIUS.md, - alignItems: 'center', - justifyContent: 'center', - }, - placeBody: { - flex: 1, - gap: 3, - }, - placeName: { - fontFamily: FONTS.semibold, - fontSize: 14, - color: colors.text, - }, - placeMetaRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 3, - }, - placeMeta: { - fontFamily: FONTS.regular, - fontSize: 12, - color: colors.textMuted, - }, - actionCircle: { - width: 36, - height: 36, - borderRadius: RADIUS.md, - backgroundColor: colors.cardMuted, - alignItems: 'center', - justifyContent: 'center', - }, - actionCircleWished: { - borderColor: '#FEE2E2', - backgroundColor: '#FEF2F2', - }, - actionCircleMap: { - borderColor: colors.primaryPale, - backgroundColor: colors.primaryPale, - }, - actionCircleNav: { - borderColor: colors.primary, - backgroundColor: colors.primary, - }, - - // ── Empty state ── - empty: { - alignItems: 'center', - paddingHorizontal: SPACING.xl, - paddingTop: SPACING.xl, - gap: SPACING.sm, - }, - emptyTitle: { - fontFamily: FONTS.semibold, - fontSize: 16, - color: colors.text, - textAlign: 'center', - }, - emptyText: { - fontFamily: FONTS.regular, - fontSize: 13, - lineHeight: 20, - color: colors.textSecondary, - textAlign: 'center', - }, - }); -} diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 7cbf569..e33c76d 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,9 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { - Animated, - FlatList, + ActivityIndicator, ImageBackground, - RefreshControl, ScrollView, StyleSheet, Text, @@ -11,856 +9,232 @@ import { View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FONTS } from '../../src/constants/fonts'; import { RADIUS, SPACING } from '../../src/constants/spacing'; import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; -import { extractApiData, getUserDisplayName, type AuthUser } from '../../src/utils/auth'; import { homeAPI } from '../../src/utils/api'; -import { KEYS, getJSON, saveJSON } from '../../src/utils/storage'; -import { - HOME_DEFAULT_HERO, - PLACE_FILTERS, - buildPlaceParams, - getPlaceTypeLabel, - normalizeAgencies, - normalizeHeroSlides, - normalizePopularPlaces, - normalizeTours, - type HomeAgencyItem, - type HomeHeroSlide, - type HomePayload, - type HomePlaceType, - type HomeTourItem, - type PopularPlaceItem, -} from '../../src/utils/homeContent'; - -type HeroSlidesPayload = { - items?: unknown[]; -}; - -function firstNameFromUser(user: AuthUser | null) { - if (!user) return 'Sayohatchi'; - const displayName = getUserDisplayName(user).trim(); - return displayName.split(/\s+/)[0] || 'Sayohatchi'; -} - -function getPlaceImage(item: PopularPlaceItem) { - return item.imageUrl || null; -} +import { extractApiData } from '../../src/utils/auth'; +import { normalizeTours, serializeTourParam, type HomeTourItem } from '../../src/utils/homeContent'; +import { STITCH_IMAGES } from '../../src/components/stitch/StitchMobile'; export default function HomeScreen() { - const insets = useSafeAreaInsets(); const { colors } = useAppTheme(); + const insets = useSafeAreaInsets(); const styles = createStyles(colors); - const fadeAnim = useRef(new Animated.Value(0)).current; - const slideAnim = useRef(new Animated.Value(26)).current; - const heroFadeAnim = useRef(new Animated.Value(1)).current; - const heroTextAnim = useRef(new Animated.Value(0)).current; - const [refreshing, setRefreshing] = useState(false); - const [popularPlaces, setPopularPlaces] = useState([]); - const [homeTours, setHomeTours] = useState([]); - const [homeAgencies, setHomeAgencies] = useState([]); - const [heroSlides, setHeroSlides] = useState([]); - const [placeFilter, setPlaceFilter] = useState('all'); - const [heroIndex, setHeroIndex] = useState(0); - const [user, setUser] = useState(null); - - const loadHomeData = useCallback(async () => { - const [savedUser, cached] = await Promise.all([ - getJSON(KEYS.USER).catch(() => null), - getJSON(KEYS.HOME_CACHE_V2).catch(() => null), - ]); - - setUser(savedUser); - const cachedPlaces = normalizePopularPlaces(cached?.places || []); - const cachedTours = normalizeTours(cached?.tours || []); - const cachedAgencies = normalizeAgencies(cached?.agencies || []); - const cachedHeroSlides = normalizeHeroSlides(cached?.heroSlides || []); - if (cachedPlaces.length > 0) { - setPopularPlaces(cachedPlaces); - } - if (cachedTours.length > 0) setHomeTours(cachedTours); - if (cachedAgencies.length > 0) setHomeAgencies(cachedAgencies); - if (cachedHeroSlides.length > 0) setHeroSlides(cachedHeroSlides); + const [tours, setTours] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); - const [homeResult, heroSlidesResult] = await Promise.allSettled([ - homeAPI.getHome({ limit: 48 }), - homeAPI.getHeroSlides({ limit: 8 }), - ]); - - try { - if (homeResult.status === 'rejected' && heroSlidesResult.status === 'rejected') { - throw homeResult.reason || heroSlidesResult.reason; + useEffect(() => { + let alive = true; + (async () => { + try { + const payload = extractApiData<{ items?: HomeTourItem[]; total?: number }>( + await homeAPI.getTours({ agencyOnly: true, limit: 6, page: 1 }) + ); + if (!alive) return; + setTours(normalizeTours(payload?.items || [])); + setTotal(Number.isFinite(Number(payload?.total)) ? Number(payload?.total) : 0); + } catch { + if (alive) { setTours([]); setTotal(0); } + } finally { + if (alive) setLoading(false); } - - const hasFreshHomePayload = homeResult.status === 'fulfilled'; - const payload: Partial = - homeResult.status === 'fulfilled' - ? extractApiData(homeResult.value) || {} - : {}; - const heroPayload: HeroSlidesPayload = - heroSlidesResult.status === 'fulfilled' - ? extractApiData(heroSlidesResult.value) || {} - : {}; - const nextPlaces = hasFreshHomePayload ? normalizePopularPlaces(payload.places || []) : cachedPlaces; - const nextTours = hasFreshHomePayload ? normalizeTours(payload.tours || []) : cachedTours; - const nextAgencies = hasFreshHomePayload ? normalizeAgencies(payload.agencies || []) : cachedAgencies; - const directHeroSlides = normalizeHeroSlides(heroPayload.items || []); - const nextHeroSlides = directHeroSlides.length > 0 ? directHeroSlides : normalizeHeroSlides(payload.heroSlides || []); - - setPopularPlaces(nextPlaces); - setHomeTours(nextTours); - setHomeAgencies(nextAgencies); - setHeroSlides(nextHeroSlides); - await saveJSON(KEYS.HOME_CACHE_V2, { - places: nextPlaces, - tours: nextTours, - agencies: nextAgencies, - heroSlides: nextHeroSlides, - stats: payload.stats, - updatedAt: payload.updatedAt, - }); - await saveJSON(KEYS.HOME_STATS_CACHE, { - poi: nextPlaces.length, - tours: nextTours.length, - agencies: nextAgencies.length, - }); - } catch { - if (cachedPlaces.length === 0) setPopularPlaces([]); - if (cachedTours.length === 0) setHomeTours([]); - if (cachedAgencies.length === 0) setHomeAgencies([]); - if (cachedHeroSlides.length === 0) setHeroSlides([]); - } + })(); + return () => { alive = false; }; }, []); - useEffect(() => { - void loadHomeData(); - Animated.parallel([ - Animated.timing(fadeAnim, { toValue: 1, duration: 650, useNativeDriver: true }), - Animated.timing(slideAnim, { toValue: 0, duration: 650, useNativeDriver: true }), - ]).start(); - }, [fadeAnim, loadHomeData, slideAnim]); - - const computedHeroSlides = useMemo(() => { - const backendSlides = heroSlides.filter((item) => item.imageUrl); - return backendSlides.length > 0 ? backendSlides.slice(0, 6) : [HOME_DEFAULT_HERO]; - }, [heroSlides]); - - useEffect(() => { - if (computedHeroSlides.length <= 1) return; - const timer = setInterval(() => { - Animated.parallel([ - Animated.timing(heroFadeAnim, { toValue: 0, duration: 280, useNativeDriver: true }), - Animated.timing(heroTextAnim, { toValue: -12, duration: 280, useNativeDriver: true }), - ]).start(() => { - setHeroIndex((current) => (current + 1) % computedHeroSlides.length); - heroTextAnim.setValue(12); - Animated.parallel([ - Animated.timing(heroFadeAnim, { toValue: 1, duration: 520, useNativeDriver: true }), - Animated.timing(heroTextAnim, { toValue: 0, duration: 520, useNativeDriver: true }), - ]).start(); - }); - }, 4200); - return () => clearInterval(timer); - }, [computedHeroSlides.length, heroFadeAnim, heroTextAnim]); - - useEffect(() => { - if (heroIndex >= computedHeroSlides.length) setHeroIndex(0); - }, [heroIndex, computedHeroSlides.length]); - - const onRefresh = async () => { - setRefreshing(true); - await loadHomeData(); - setRefreshing(false); - }; - - const userName = firstNameFromUser(user); - const activeHero = computedHeroSlides[heroIndex] || HOME_DEFAULT_HERO; - const filteredPlaces = useMemo( - () => (placeFilter === 'all' ? popularPlaces : popularPlaces.filter((item) => item.type === placeFilter)), - [placeFilter, popularPlaces] - ); - const popularPlaceSlides = useMemo( - () => [...popularPlaces].sort((a, b) => Number(b.rating || 0) - Number(a.rating || 0)).slice(0, 8), - [popularPlaces] - ); - const latestTours = useMemo(() => homeTours.filter((tour) => tour.badge === 'Latest'), [homeTours]); - const popularTours = useMemo(() => homeTours.filter((tour) => tour.badge === 'Popular'), [homeTours]); - - const openPlace = (item: PopularPlaceItem) => { - router.push({ - pathname: '/place/[slug]', - params: buildPlaceParams(item), - }); - }; - - const renderPlaceCard = ({ item }: { item: PopularPlaceItem }) => ( - openPlace(item)}> - - - - - - - {item.name} - - {item.city || 'Global'} · {getPlaceTypeLabel(item.type)} - - - - {item.rating ? item.rating.toFixed(1) : 'Yandex'} - - - - - ); - - const openTour = (item: HomeTourItem) => { - router.push({ - pathname: '/tour-details', - params: { tour: encodeURIComponent(JSON.stringify(item)) }, - } as any); - }; - - const renderTourCard = ({ item }: { item: HomeTourItem }) => ( - openTour(item)}> - - - - {item.badge} - - - - {item.title} - {item.subtitle} - - {item.duration} - {item.price} - - - - ); - - const renderAgency = (item: HomeAgencyItem) => ( - router.push('/home-agencies' as any)}> - - {item.name.split(' ').map((part) => part[0]).join('').slice(0, 2)} - - - {item.name} - {item.city} · {item.specialty} - - - - {item.rating.toFixed(1)} - - - ); + const goTours = () => router.push('/(tabs)/tours' as any); + const openTour = (item: HomeTourItem) => + router.push({ pathname: '/tour-details', params: { tour: serializeTourParam(item) } } as any); return ( } > - - router.push('/side-menu' as any)} activeOpacity={0.82}> - - - TravelorAI - router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> - + {/* Top bar */} + + + TravelorAI + Tasdiqlangan turlar bozori + + router.push('/(tabs)/profile' as any)}> + - - {activeHero.imageUrl ? ( - - - - - - ) : ( - - - - - - )} - - Dunyo bo‘ylab aqlli marshrut - Xush kelibsiz, - {userName}! - {activeHero.subtitle} - router.push('/(tabs)/explore' as any)} activeOpacity={0.88}> - - Qayerga sayohat qilmoqchisiz? - - - - - - {computedHeroSlides.map((slide, index) => ( - - ))} - - - - - - router.push('/home-places' as any)} styles={styles} /> - - {PLACE_FILTERS.map((filter) => { - const active = filter.key === placeFilter; - return ( - setPlaceFilter(filter.key)} activeOpacity={0.84}> - - {filter.label} - - ); - })} - - item.id} - renderItem={renderPlaceCard} - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalList} - ListEmptyComponent={Bu filter uchun joy topilmadi.} - /> - - - - router.push('/home-places' as any)} styles={styles} /> - `popular-${item.id}`} - renderItem={renderPlaceCard} - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalList} + {/* Banner */} + + - + + + + Bepul · Vositachisiz + + Ishonchli{'\n'}sayohat turlari + + Tasdiqlangan agentliklarning eng yaxshi turlarini bir joyda ko‘ring va to‘g‘ridan-to‘g‘ri bog‘laning. + + + + Turlarni ko‘rish + + + + - - router.push('/home-tours' as any)} styles={styles} /> - item.id} - renderItem={renderTourCard} - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalList} - ListEmptyComponent={Latest tours hali backendga qo‘shilmagan.} - /> + {/* Featured tours */} + + Tavsiya etilgan turlar + + Barchasi{total ? ` (${total})` : ''} + - - router.push('/home-tours' as any)} styles={styles} /> - + ) : tours.length === 0 ? ( + + + Hozircha tur yo‘q. Tez orada qo‘shiladi. + + ) : ( + item.id} - renderItem={renderTourCard} showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalList} - ListEmptyComponent={Popular tours hali backendga qo‘shilmagan.} - /> - - - - router.push('/home-agencies' as any)} styles={styles} /> - - {homeAgencies.slice(0, 3).map(renderAgency)} - {homeAgencies.length === 0 ? Agentliklar hali backendga qo‘shilmagan. : null} - - + contentContainerStyle={styles.railContent} + > + {tours.map((item) => ( + openTour(item)}> + + + {item.title} + {item.city || 'Global'}{item.duration ? ` · ${item.duration}` : ''} + + + + {Number(item.rating || 0).toFixed(1)} + + {item.price || 'So‘rovda'} + + + + ))} + + )} - + ); } -function SectionHeader({ - title, - action, - onPress, - styles, -}: { - title: string; - action: string; - onPress: () => void; - styles: ReturnType; -}) { - return ( - - {title} - - {action} - - - ); -} - -function PlaceCardMedia({ - imageUrl, - children, - styles, - colors, +function TourThumb({ + imageUrl, styles, colors, badge, }: { imageUrl: string | null; - children: React.ReactNode; styles: ReturnType; colors: AppColors; + badge?: string; }) { - if (imageUrl) { - return ( - - {children} - - ); - } - - return ( - - - {children} - + const Badge = ( + {badge || 'Tur'} ); -} - -function TourCardMedia({ - imageUrl, - children, - styles, - colors, -}: { - imageUrl: string | null; - children: React.ReactNode; - styles: ReturnType; - colors: AppColors; -}) { if (imageUrl) { return ( - - {children} + + + {Badge} ); } - return ( - - - {children} + + + {Badge} ); } function createStyles(colors: AppColors) { return StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.sm, - paddingBottom: SPACING.md, - }, - menuBtn: { - width: 38, - height: 38, - borderRadius: 19, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.surface, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 5 }, - shadowOpacity: 0.08, - shadowRadius: 12, - elevation: 2, - }, - brandText: { - fontFamily: FONTS.display, - fontSize: 19, - color: colors.text, - }, - avatar: { - width: 38, - height: 38, - borderRadius: 19, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primary, + screen: { flex: 1, backgroundColor: colors.background }, + content: { paddingHorizontal: SPACING.lg, gap: SPACING.md }, + + topbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + brand: { fontFamily: FONTS.display, fontSize: 22, color: colors.text }, + brandSub: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textMuted, marginTop: 2 }, + iconBtn: { + width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', + backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.borderLight, }, + hero: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - borderRadius: 30, - overflow: 'hidden', - backgroundColor: colors.primary, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 14 }, - shadowOpacity: 0.16, - shadowRadius: 26, - elevation: 7, - minHeight: 294, - justifyContent: 'flex-end', - }, - heroImageAnimated: { ...StyleSheet.absoluteFillObject }, - heroImage: { minHeight: 294, justifyContent: 'flex-end' }, - heroImageRadius: { borderRadius: 30 }, - heroFallback: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.primary, - overflow: 'hidden', - }, - heroGlowLarge: { - position: 'absolute', - width: 240, - height: 240, - borderRadius: 120, - right: -58, - top: -42, - backgroundColor: 'rgba(104,219,169,0.24)', - }, - heroGlowSmall: { - position: 'absolute', - width: 160, - height: 160, - borderRadius: 80, - left: -42, - bottom: -26, - backgroundColor: 'rgba(255,255,255,0.14)', - }, - heroOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(8,18,32,0.50)', - }, - heroCopy: { padding: SPACING.xl, paddingTop: 86 }, - heroKicker: { - alignSelf: 'flex-start', - marginBottom: SPACING.md, - borderRadius: RADIUS.full, - backgroundColor: 'rgba(255,255,255,0.18)', - paddingHorizontal: SPACING.md, - paddingVertical: 7, - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textInverse, - overflow: 'hidden', - }, - heroWelcome: { - fontFamily: FONTS.display, - fontSize: 34, - lineHeight: 38, - color: colors.textInverse, - }, - heroName: { - fontFamily: FONTS.display, - fontSize: 43, - lineHeight: 47, - color: colors.textInverse, - marginBottom: 8, - }, - heroSub: { - fontFamily: FONTS.regular, - fontSize: 14, - lineHeight: 20, - color: 'rgba(255,255,255,0.84)', - marginBottom: SPACING.lg, - maxWidth: 278, - }, - searchBar: { - height: 54, - borderRadius: 27, - backgroundColor: colors.surface, - flexDirection: 'row', - alignItems: 'center', - paddingLeft: SPACING.md, - paddingRight: 6, - gap: SPACING.sm, - }, - searchText: { - flex: 1, - fontFamily: FONTS.regular, - fontSize: 13, - color: colors.textMuted, - }, - searchAction: { - width: 42, - height: 42, - borderRadius: 21, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.success, - }, - heroDots: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - marginTop: SPACING.md, - justifyContent: 'center', - }, - heroDot: { - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: 'rgba(255,255,255,0.42)', - }, - heroDotActive: { - width: 18, - backgroundColor: colors.textInverse, + minHeight: 280, borderRadius: 28, overflow: 'hidden', justifyContent: 'flex-end', + shadowColor: colors.shadow, shadowOffset: { width: 0, height: 14 }, shadowOpacity: 0.2, shadowRadius: 26, elevation: 8, }, - section: { marginBottom: SPACING.xl, overflow: 'visible' }, - sectionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: SPACING.lg, - marginBottom: SPACING.sm, + heroImg: { borderRadius: 28 }, + heroBody: { padding: SPACING.xl, gap: SPACING.sm }, + heroBadge: { + alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 6, + backgroundColor: 'rgba(255,255,255,0.18)', borderRadius: RADIUS.full, paddingHorizontal: 11, paddingVertical: 6, }, - sectionTitle: { - fontFamily: FONTS.display, - fontSize: 22, - color: colors.text, + heroBadgeText: { fontFamily: FONTS.semibold, fontSize: 11, color: '#fff' }, + heroTitle: { fontFamily: FONTS.display, fontSize: 32, lineHeight: 36, color: '#fff' }, + heroSub: { fontFamily: FONTS.regular, fontSize: 14, lineHeight: 21, color: 'rgba(255,255,255,0.9)' }, + heroCta: { + marginTop: SPACING.sm, alignSelf: 'flex-start', height: 50, borderRadius: RADIUS.full, overflow: 'hidden', + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingHorizontal: SPACING.xl, }, - seeAll: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.success }, - filterRow: { - paddingLeft: SPACING.lg, - paddingRight: SPACING.lg, - gap: SPACING.sm, - paddingBottom: SPACING.sm, - }, - filterChip: { - height: 38, - borderRadius: RADIUS.full, - paddingHorizontal: SPACING.md, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.borderLight, - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - filterChipActive: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - filterChipText: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textSecondary, - }, - filterChipTextActive: { - color: colors.textInverse, - }, - horizontalList: { - paddingLeft: SPACING.lg, - paddingRight: SPACING.lg, - paddingVertical: SPACING.sm, - gap: SPACING.sm, - }, - placeSlideCard: { - width: 178, - height: 220, - borderRadius: 24, - overflow: 'hidden', - backgroundColor: colors.cardMuted, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.12, - shadowRadius: 20, - elevation: 4, - }, - placeSlideImage: { - flex: 1, - justifyContent: 'flex-end', - }, - placeSlideImageRadius: { borderRadius: 24 }, - placeholderMedia: { - backgroundColor: colors.primary, - overflow: 'hidden', - }, - cardScrim: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(7,15,28,0.30)', - }, - cardHeart: { - position: 'absolute', - top: 10, - right: 10, - width: 31, - height: 31, - borderRadius: 16, - backgroundColor: 'rgba(255,255,255,0.82)', - alignItems: 'center', - justifyContent: 'center', - }, - placeSlideBody: { - padding: SPACING.md, - gap: 5, - }, - placeSlideName: { - fontFamily: FONTS.display, - fontSize: 17, - lineHeight: 20, - color: colors.textInverse, - }, - placeSlideMeta: { - fontFamily: FONTS.regular, - fontSize: 11, - color: 'rgba(255,255,255,0.80)', - }, - ratingPill: { - alignSelf: 'flex-start', - height: 24, - borderRadius: RADIUS.full, - backgroundColor: 'rgba(255,255,255,0.88)', - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 8, - marginTop: 2, - }, - ratingText: { - fontFamily: FONTS.semibold, - fontSize: 10, - color: colors.text, + heroCtaText: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.onGradient }, + + h2: { fontFamily: FONTS.display, fontSize: 21, color: colors.text, marginTop: SPACING.xs }, + sectionRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: SPACING.xs }, + sectionAction: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.primary }, + + loadingBox: { paddingVertical: SPACING.xl, alignItems: 'center' }, + emptyBox: { + paddingVertical: SPACING.xl, alignItems: 'center', gap: SPACING.sm, borderRadius: 20, + backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.borderLight, }, + emptyText: { fontFamily: FONTS.regular, fontSize: 13, color: colors.textMuted, textAlign: 'center' }, + + railContent: { gap: SPACING.md, paddingRight: SPACING.lg, paddingVertical: 2 }, tourCard: { - width: 254, - borderRadius: 24, - overflow: 'hidden', - backgroundColor: colors.surface, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.1, - shadowRadius: 20, - elevation: 4, - }, - tourImage: { - height: 126, - padding: SPACING.sm, - alignItems: 'flex-start', - }, - tourImageRadius: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - }, + width: 224, borderRadius: 22, backgroundColor: colors.surface, overflow: 'hidden', + borderWidth: 1, borderColor: colors.borderLight, + shadowColor: colors.shadow, shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.1, shadowRadius: 18, elevation: 3, + }, + tourImg: { height: 126, padding: SPACING.sm }, + tourImgRadius: { borderTopLeftRadius: 22, borderTopRightRadius: 22 }, + tourPlaceholder: { backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, + tourScrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(7,15,28,0.26)' }, tourBadge: { - borderRadius: RADIUS.full, - backgroundColor: colors.success, - paddingHorizontal: 10, - paddingVertical: 5, - }, - tourBadgeText: { - fontFamily: FONTS.semibold, - fontSize: 10, - color: colors.textInverse, - }, - tourBody: { - padding: SPACING.md, - gap: 6, - }, - tourTitle: { - fontFamily: FONTS.display, - fontSize: 18, - color: colors.text, - }, - tourSub: { - fontFamily: FONTS.regular, - fontSize: 12, - lineHeight: 17, - color: colors.textMuted, - }, - tourMetaRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - gap: SPACING.sm, - }, - tourMeta: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textSecondary, - }, - tourPrice: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.success, - }, - agencyCard: { - marginHorizontal: SPACING.lg, - borderRadius: 24, - backgroundColor: colors.surface, - padding: SPACING.sm, - gap: SPACING.xs, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.08, - shadowRadius: 20, - elevation: 3, - }, - agencyRow: { - minHeight: 70, - borderRadius: 20, - backgroundColor: colors.cardMuted, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: SPACING.md, - gap: SPACING.md, - }, - agencyAvatar: { - width: 46, - height: 46, - borderRadius: 16, - backgroundColor: colors.successPale, - alignItems: 'center', - justifyContent: 'center', - }, - agencyAvatarText: { - fontFamily: FONTS.display, - fontSize: 13, - color: colors.success, - }, - agencyCopy: { flex: 1 }, - agencyName: { - fontFamily: FONTS.semibold, - fontSize: 14, - color: colors.text, - }, - agencySub: { - marginTop: 3, - fontFamily: FONTS.regular, - fontSize: 11, - color: colors.textMuted, - }, - agencyRating: { - height: 30, - borderRadius: 15, - backgroundColor: colors.surface, - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 9, - }, - agencyRatingText: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.text, - }, - emptyText: { - width: 260, - fontFamily: FONTS.regular, - fontSize: 13, - color: colors.textMuted, - paddingVertical: SPACING.lg, - }, + alignSelf: 'flex-start', borderRadius: RADIUS.full, backgroundColor: 'rgba(255,255,255,0.26)', + paddingHorizontal: 8, paddingVertical: 4, + }, + tourBadgeText: { fontFamily: FONTS.semibold, fontSize: 9, letterSpacing: 0.5, color: '#fff' }, + tourBody: { padding: SPACING.md, gap: 6 }, + tourTitle: { fontFamily: FONTS.display, fontSize: 15, lineHeight: 19, color: colors.text }, + tourCity: { fontFamily: FONTS.medium, fontSize: 11, color: colors.textMuted }, + tourMeta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 2 }, + tourRating: { flexDirection: 'row', alignItems: 'center', gap: 3 }, + tourRatingText: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.text }, + tourPrice: { flex: 1, textAlign: 'right', fontFamily: FONTS.semibold, fontSize: 11, color: colors.success }, + }); } diff --git a/mobile/app/(tabs)/planner.tsx b/mobile/app/(tabs)/planner.tsx deleted file mode 100644 index 073a2a5..0000000 --- a/mobile/app/(tabs)/planner.tsx +++ /dev/null @@ -1,1773 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - Animated, - Alert, - ImageBackground, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router, useFocusEffect } from 'expo-router'; -import { useTranslation } from 'react-i18next'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { FONTS } from '../../src/constants/fonts'; -import { RADIUS, SPACING } from '../../src/constants/spacing'; -import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; -import { citiesAPI, destinationsAPI, homeAPI, plannerAPI, poiAPI, type PoiPayload } from '../../src/utils/api'; -import { extractApiData } from '../../src/utils/auth'; -import { formatSum } from '../../src/utils/formatter'; -import { normalizeTours, type HomeTourItem } from '../../src/utils/homeContent'; -import { - INTEREST_OPTIONS, - TRAVEL_STYLE_OPTIONS, - type TravelStyle, -} from '../../src/utils/preferences'; -import { KEYS, getItem, getJSON, saveJSON } from '../../src/utils/storage'; - -type Companions = 'solo' | 'friends' | 'family' | 'couple'; -type FoodPref = 'halal' | 'vegetarian' | 'vegan' | 'none'; -type TransportPref = 'cheap' | 'fast' | 'comfort'; -type Flexibility = 'fixed' | 'flexible'; -type Currency = 'UZS' | 'USD'; - -interface FormState { - city: string; - startDate: string; - endDate: string; - companions: Companions; - travelers: number; - budget: string; - currency: Currency; - style: TravelStyle; - interests: string[]; - food: FoodPref; - transport: TransportPref; - flexibility: Flexibility; -} - -const MIN_BUDGET_UZS = 200000; -const USD_TO_UZS = Number.parseInt(process.env.EXPO_PUBLIC_USD_TO_UZS || '', 10) || 13000; -const MAX_DAYS = 14; -const POI_PAGE_LIMIT = 200; -const POI_MAX_PAGES = 4; -const POI_REQUEST_TIMEOUT_MS = 12000; -const POI_CONTEXT_SOFT_TIMEOUT_MS = 8000; -const CITIES_FALLBACK: string[] = []; -const ACTIVITY_ICONS: Record = { transport: 'TR', landmark: 'LM', food: 'FD', attraction: 'AT', hotel: 'HT' }; - -type PlanMeta = { - status: 'draft' | 'final' | 'failed'; - source: 'local' | 'backend'; -}; - -interface PlannerPoiInsight { - city: string; - totalPoi: number; - typeCounts: Record; - avgPriceByType: Record; - recommendedNames: string[]; - estimatedDayCost: number; - estimatedTripCost: number; -} - -interface PlannerPoiContextItem { - id?: string; - name: string; - city: string; - type?: string; - subtype?: string | null; - info?: string; - description?: string | null; - lat?: number; - lng?: number; - price?: number; - rating?: number; - icon?: string; -} - -interface NormalizePlanLabels { - tripTitle: string; - highlightDates: string; - highlightCompanions: string; -} - -const INTEREST_KEYWORDS: Record = { - tarixiy: ['history', 'historical', 'museum', 'fortress', 'ark', 'qala', 'madrasah', 'maqbara'], - madaniy: ['culture', 'art', 'heritage', 'theatre', 'center', 'gallery'], - tabiat: ['nature', 'park', 'garden', 'lake', 'river', 'eco', 'forest'], - gastronomiya: ['food', 'restaurant', 'cafe', 'tea', 'local dish'], - gastronomy: ['food', 'restaurant', 'cafe', 'tea', 'local dish'], - arxitektura: ['architecture', 'building', 'tower', 'mosque', 'minaret'], - din: ['mosque', 'ziyorat', 'shrine', 'religious'], - zamonaviy: ['mall', 'modern', 'shopping', 'entertainment'], - hunarmandchilik: ['craft', 'workshop', 'artisan', 'bazaar'], -}; - -function normalizeCityName(value: unknown): string { - return String(value || '').trim(); -} - -function uniqueCities(values: unknown[]): string[] { - return Array.from( - new Set( - values - .map((value) => normalizeCityName(value)) - .filter(Boolean) - ) - ); -} - -function getDestinationNamesFromItems(items: any[]): string[] { - return uniqueCities(items.map((item) => item?.name)); -} - -function mergeCityLists(...lists: (string[] | undefined)[]): string[] { - const merged: string[] = []; - for (const list of lists) { - if (!Array.isArray(list) || list.length === 0) continue; - for (const city of list) { - const normalized = normalizeCityName(city); - if (!normalized) continue; - if (!merged.some((item) => item.toLowerCase() === normalized.toLowerCase())) { - merged.push(normalized); - } - } - } - return merged; -} - -function normalizeSearchText(value: string): string { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function fallbackTypePrice(type: string, style: TravelStyle): number { - if (type === 'restaurant') return style === 'budget' ? 55_000 : style === 'mid' ? 95_000 : 165_000; - if (type === 'hotel') return style === 'budget' ? 180_000 : style === 'mid' ? 310_000 : 520_000; - if (type === 'transport') return style === 'budget' ? 28_000 : style === 'mid' ? 42_000 : 70_000; - return style === 'budget' ? 35_000 : style === 'mid' ? 60_000 : 105_000; -} - -function estimatePoiPrice(poi: PoiPayload, style: TravelStyle): number { - if (Number(poi.price || 0) > 0) return Number(poi.price); - const type = String(poi.type || ''); - return fallbackTypePrice(type, style); -} - -function buildPoiInsight(city: string, points: PoiPayload[], form: FormState, duration: number): PlannerPoiInsight { - const typeCounts: Record = { landmark: 0, restaurant: 0, hotel: 0, transport: 0 }; - const avgPriceByType: Record = { landmark: 0, restaurant: 0, hotel: 0, transport: 0 }; - const grouped: Record = { landmark: [], restaurant: [], hotel: [], transport: [] }; - - points.forEach((poi) => { - const type = String(poi.type || ''); - if (!grouped[type]) return; - typeCounts[type] += 1; - grouped[type].push(estimatePoiPrice(poi, form.style)); - }); - - Object.keys(grouped).forEach((type) => { - const list = grouped[type]; - if (!list.length) return; - avgPriceByType[type] = Math.round(list.reduce((sum, value) => sum + value, 0) / list.length); - }); - - const keywords = form.interests.flatMap((interest) => INTEREST_KEYWORDS[interest] || [interest]); - const recommended = points - .map((poi) => { - const haystack = normalizeSearchText(`${poi.name} ${poi.info} ${poi.subtype || ''}`); - const score = - keywords.reduce((sum, word) => (haystack.includes(normalizeSearchText(word)) ? sum + 2 : sum), 0) + - (poi.type === 'landmark' ? 1 : 0); - return { poi, score }; - }) - .sort((a, b) => b.score - a.score) - .slice(0, 12) - .map((item) => item.poi.name); - - const dayCostBase = - (avgPriceByType.transport || fallbackTypePrice('transport', form.style)) + - (avgPriceByType.landmark || fallbackTypePrice('landmark', form.style)) * 2 + - (avgPriceByType.restaurant || fallbackTypePrice('restaurant', form.style)) * 2 + - (avgPriceByType.hotel || fallbackTypePrice('hotel', form.style)); - const travelers = Math.max(1, form.travelers); - const estimatedDayCost = Math.round(dayCostBase * travelers); - const estimatedTripCost = Math.round(estimatedDayCost * Math.max(1, duration)); - - return { - city, - totalPoi: points.length, - typeCounts, - avgPriceByType, - recommendedNames: recommended, - estimatedDayCost, - estimatedTripCost, - }; -} - -function mapPoiToContextItem(poi: PoiPayload, fallbackCity: string): PlannerPoiContextItem | null { - const name = String(poi?.name || '').trim(); - if (!name) return null; - - const city = String(poi?.city || fallbackCity || '').trim() || fallbackCity; - const type = String(poi?.type || '').trim(); - const lat = Number(poi?.lat); - const lng = Number(poi?.lng); - const price = Number(poi?.price); - const rating = Number(poi?.rating); - - const item: PlannerPoiContextItem = { - id: poi?.id || undefined, - name, - city, - type: type || undefined, - subtype: poi?.subtype ?? undefined, - info: String(poi?.info || '').trim() || undefined, - description: poi?.description ?? undefined, - lat: Number.isFinite(lat) ? lat : undefined, - lng: Number.isFinite(lng) ? lng : undefined, - price: Number.isFinite(price) ? price : undefined, - rating: Number.isFinite(rating) ? rating : undefined, - icon: poi?.icon || undefined, - }; - - return item; -} - -function buildPoiContextItems(cityName: string, points: PoiPayload[]): PlannerPoiContextItem[] { - const seen = new Set(); - const contextItems: PlannerPoiContextItem[] = []; - - points.forEach((poi) => { - const item = mapPoiToContextItem(poi, cityName); - if (!item) return; - - const dedupeKey = `${normalizeSearchText(item.city)}|${normalizeSearchText(item.name)}|${normalizeSearchText(item.type || '')}`; - if (seen.has(dedupeKey)) return; - seen.add(dedupeKey); - contextItems.push(item); - }); - - return contextItems; -} - -async function loadCityPoiPoints(cityName: string): Promise { - const points: PoiPayload[] = []; - let expectedTotal = Number.POSITIVE_INFINITY; - - for (let page = 1; page <= POI_MAX_PAGES; page += 1) { - const poiResponse = extractApiData( - await poiAPI.getAll({ city: cityName, page, limit: POI_PAGE_LIMIT }, POI_REQUEST_TIMEOUT_MS) - ); - const poiItems = Array.isArray(poiResponse?.items) - ? poiResponse.items - : Array.isArray(poiResponse) - ? poiResponse - : []; - const typedPoints = poiItems as PoiPayload[]; - - if (!typedPoints.length) break; - points.push(...typedPoints); - - const total = Number(poiResponse?.total); - if (Number.isFinite(total) && total >= 0) { - expectedTotal = total; - } - - if (points.length >= expectedTotal || typedPoints.length < POI_PAGE_LIMIT) { - break; - } - } - - return points; -} - -function getErrorMessage(err: unknown): string { - const e = err as any; - return String(e?.data?.message || e?.message || '').trim(); -} - -function buildPlannerPayload( - form: FormState, - cityName: string, - departureCity: string, - duration: number, - budgetUzs: number, - poiInsight: PlannerPoiInsight | null, - poiContext: PlannerPoiContextItem[] -) { - return { - country: 'Global', - city: cityName, - startDate: form.startDate, - endDate: form.endDate, - companions: form.companions, - budget: budgetUzs, - comfortLevel: form.style, - interests: form.interests, - foodPreferences: form.food, - transportType: form.transport, - flexibility: form.flexibility, - duration, - travelers: form.travelers, - style: form.style, - departureCity, - analysisContext: poiInsight - ? { - city: poiInsight.city, - totalPoi: poiInsight.totalPoi, - typeCounts: poiInsight.typeCounts, - avgPriceByType: poiInsight.avgPriceByType, - estimatedDayCost: poiInsight.estimatedDayCost, - estimatedTripCost: poiInsight.estimatedTripCost, - } - : undefined, - recommendedPoiNames: poiInsight?.recommendedNames.slice(0, 12) || [], - poiContext, - }; -} - -function toISO(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; -} - -function parseISO(value: string): Date | null { - const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!m) return null; - const year = Number(m[1]); - const month = Number(m[2]); - const day = Number(m[3]); - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null; - if (month < 1 || month > 12 || day < 1 || day > 31) return null; - - const d = new Date(year, month - 1, day); - if (Number.isNaN(d.getTime())) return null; - if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null; - d.setHours(0, 0, 0, 0); - return d; -} - -function addDays(iso: string, days: number): string { - const date = parseISO(iso); - if (!date) return iso; - date.setDate(date.getDate() + days); - return toISO(date); -} - -function getDuration(start: string, end: string): number { - const s = parseISO(start); - const e = parseISO(end); - if (!s || !e) return 0; - return Math.floor((e.getTime() - s.getTime()) / 86400000) + 1; -} - -function toSafeNumber(value: unknown): number { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function normalizeBreakdown(raw: any) { - return { - transport: toSafeNumber(raw?.transport), - accommodation: toSafeNumber(raw?.accommodation), - food: toSafeNumber(raw?.food), - attractions: toSafeNumber(raw?.attractions), - misc: toSafeNumber(raw?.misc), - }; -} - -function normalizePlan(raw: any, form: FormState, duration: number, extraTips: string[], labels: NormalizePlanLabels, meta: PlanMeta) { - const rawDays = Array.isArray(raw?.days) ? raw.days : []; - const breakdown = normalizeBreakdown(raw?.breakdown); - const breakdownTotal = Object.values(breakdown).reduce((sum, value) => sum + value, 0); - const totalCost = Math.max(toSafeNumber(raw?.totalCost), breakdownTotal); - - const mappedDays = - rawDays.length > 0 - ? rawDays.map((d: any, i: number) => { - const activities = Array.isArray(d?.activities) ? d.activities : []; - const destination = String(d?.destination ?? d?.city ?? form.city ?? '').trim() || form.city; - return { - day: d.day ?? d.dayNumber ?? i + 1, - destination, - activities: activities.map((a: any) => ({ - time: a.time ?? '09:00', - type: a.type ?? 'attraction', - name: String(a.name ?? '').trim(), - cost: toSafeNumber(a.cost), - icon: a.icon ?? ACTIVITY_ICONS[a.type] ?? 'AT', - lat: Number.isFinite(Number(a.lat)) ? Number(a.lat) : undefined, - lng: Number.isFinite(Number(a.lng)) ? Number(a.lng) : undefined, - source: a.source, - confidenceScore: Number.isFinite(Number(a.confidenceScore)) ? Number(a.confidenceScore) : undefined, - bookingUrl: a.bookingUrl, - durationMinutes: Number.isFinite(Number(a.durationMinutes)) ? Number(a.durationMinutes) : undefined, - distanceKm: Number.isFinite(Number(a.distanceKm)) ? Number(a.distanceKm) : undefined, - note: a.note, - })), - hotel: d.hotel ?? d.accommodation?.name ?? 'Hotel', - hotelCost: toSafeNumber(d.hotelCost ?? d.accommodation?.cost ?? 0), - }; - }) - : Array.from({ length: duration }, (_, i) => ({ - day: i + 1, - destination: form.city, - activities: [], - hotel: '', - hotelCost: 0, - })); - - const warnings = Array.isArray(raw?.warnings) ? raw.warnings.map((item: any) => String(item || '').trim()).filter(Boolean) : []; - const highlightsRaw = Array.isArray(raw?.highlights) ? raw.highlights.map((item: any) => String(item || '').trim()).filter(Boolean) : []; - const tipsRaw = Array.isArray(raw?.tips) ? raw.tips.map((item: any) => String(item || '').trim()).filter(Boolean) : []; - const status = raw?.status === 'draft' || raw?.status === 'final' || raw?.status === 'failed' ? raw.status : meta.status; - const source = raw?.source === 'local' || raw?.source === 'backend' ? raw.source : meta.source; - - return { - id: raw?.id ?? String(Date.now()), - title: raw?.title ?? labels.tripTitle.replace('{{city}}', form.city), - totalCost, - duration: Math.max(1, toSafeNumber(raw?.duration ?? duration)), - travelers: Math.max(1, toSafeNumber(raw?.travelers ?? form.travelers)), - style: raw?.style ?? form.style, - destinations: Array.isArray(raw?.destinations) && raw.destinations.length ? raw.destinations : [form.city], - transportLegs: Array.isArray(raw?.transportLegs) ? raw.transportLegs : [], - breakdown, - days: mappedDays, - warnings, - highlights: Array.from( - new Set([ - ...highlightsRaw, - labels.highlightDates.replace('{{city}}', form.city).replace('{{start}}', form.startDate).replace('{{end}}', form.endDate), - labels.highlightCompanions.replace('{{companions}}', form.companions), - ]) - ).slice(0, 6), - tips: Array.from(new Set([...tipsRaw, ...extraTips.map((item) => String(item || '').trim()).filter(Boolean)])).slice(0, 8), - dataConfidence: raw?.dataConfidence, - sourceSummary: raw?.sourceSummary, - alternatives: raw?.alternatives, - verificationWarnings: Array.isArray(raw?.verificationWarnings) ? raw.verificationWarnings : [], - status, - source, - syncStatus: raw?.syncStatus, - updatedAt: new Date().toISOString(), - createdAt: raw?.createdAt ?? new Date().toISOString(), - }; -} - -export default function PlannerScreen() { - const insets = useSafeAreaInsets(); - const safeBottom = Math.max(insets.bottom, 22); - const { colors } = useAppTheme(); - const { t } = useTranslation(); - const styles = useMemo(() => createStyles(colors), [colors]); - const tt = useCallback((k: string, def: string) => t(k as any, { defaultValue: def }), [t]); - - const today = useMemo(() => toISO(new Date()), []); - const [isAuthed, setIsAuthed] = useState(false); - const [authBootstrapDone, setAuthBootstrapDone] = useState(false); - const [step, setStep] = useState(0); - const [plannerView, setPlannerView] = useState<'tours' | 'builder'>('tours'); - const [agencyTours, setAgencyTours] = useState([]); - const [agencyToursLoading, setAgencyToursLoading] = useState(false); - const [actionMenuOpen, setActionMenuOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [loadingStageIndex, setLoadingStageIndex] = useState(0); - const [analysisSummary, setAnalysisSummary] = useState(null); - const [cities, setCities] = useState(CITIES_FALLBACK); - const [form, setForm] = useState({ - city: '', - startDate: today, - endDate: addDays(today, 2), - companions: 'solo', - travelers: 1, - budget: '', - currency: 'UZS', - style: 'mid', - interests: ['tarixiy', 'madaniy'], - food: 'halal', - transport: 'cheap', - flexibility: 'fixed', - }); - - const loadAgencyTours = useCallback(async () => { - setAgencyToursLoading(true); - try { - const payload = extractApiData<{ items?: HomeTourItem[] }>( - await homeAPI.getTours({ agencyOnly: true, limit: 6, page: 1 }) - ); - setAgencyTours(normalizeTours(payload?.items || [])); - } finally { - setAgencyToursLoading(false); - } - }, []); - - useEffect(() => { - void loadAgencyTours().catch(() => { - setAgencyTours([]); - setAgencyToursLoading(false); - }); - }, [loadAgencyTours]); - - const openAgencyTour = useCallback((item: HomeTourItem) => { - router.push({ - pathname: '/tour-details', - params: { tour: encodeURIComponent(JSON.stringify(item)) }, - } as any); - }, []); - - const openManualTrip = useCallback(() => { - setActionMenuOpen(false); - router.push('/manual-trip' as any); - }, []); - - const openAiTrip = useCallback(() => { - setActionMenuOpen(false); - router.push('/ai-trip-setup' as any); - }, []); - - const steps = useMemo( - () => [ - tt('planner.flowBasics', 'Shahar va sanalar'), - tt('planner.flowCompanions', 'Hamrohlar'), - tt('planner.flowBudget', 'Budjet va comfort'), - tt('planner.flowInterests', 'Qiziqishlar'), - tt('planner.flowMobility', 'Ovqat va transport'), - tt('planner.flowFinal', 'Moslashuvchanlik'), - ], - [tt] - ); - const analysisStages = useMemo( - () => [ - tt('planner.loadingStagePlaces', 'Analyzing city places...'), - tt('planner.loadingStageBudget', 'Checking budget and price fit...'), - tt('planner.loadingStageRoute', 'Optimizing daily route...'), - tt('planner.loadingStageFinal', 'Preparing final AI plan...'), - ], - [tt] - ); - const companionLabels: Record = useMemo( - () => ({ - solo: tt('planner.companionSolo', 'Solo'), - friends: tt('planner.companionFriends', 'Friends'), - family: tt('planner.companionFamily', 'Family'), - couple: tt('planner.companionCouple', 'Couple'), - }), - [tt] - ); - const foodLabels: Record = useMemo( - () => ({ - halal: tt('planner.foodHalal', 'Halal'), - vegetarian: tt('planner.foodVegetarian', 'Vegetarian'), - vegan: tt('planner.foodVegan', 'Vegan'), - none: tt('planner.foodNone', 'No preference'), - }), - [tt] - ); - const transportLabels: Record = useMemo( - () => ({ - cheap: tt('planner.transportCheap', 'Cheap'), - fast: tt('planner.transportFast', 'Fast'), - comfort: tt('planner.transportComfort', 'Comfort'), - }), - [tt] - ); - const flexibilityLabels: Record = useMemo( - () => ({ - fixed: tt('planner.flexFixed', 'Fixed schedule'), - flexible: tt('planner.flexFlexible', 'Flexible'), - }), - [tt] - ); - const datePresets = useMemo( - () => [ - { days: 3, label: tt('planner.datePreset3', '3 kun') }, - { days: 5, label: tt('planner.datePreset5', '5 kun') }, - { days: 7, label: tt('planner.datePreset7', '7 kun') }, - ], - [tt] - ); - const normalizePlanLabels: NormalizePlanLabels = useMemo( - () => ({ - tripTitle: tt('planner.localTripTitle', '{{city}} trip plan'), - highlightDates: tt('planner.localHighlightDates', '{{city}}: {{start}} - {{end}}'), - highlightCompanions: tt('planner.localHighlightCompanions', 'Companions: {{companions}}'), - }), - [tt] - ); - const styleLabels: Record = useMemo( - () => ({ - budget: t('travelPrefs.styleBudgetLabel'), - mid: t('travelPrefs.styleMidLabel'), - luxury: t('travelPrefs.stylePremiumLabel'), - }), - [t] - ); - const interestLabels: Record = useMemo( - () => ({ - tarixiy: t('travelPrefs.intHistorical'), - madaniy: t('travelPrefs.intCultural'), - tabiat: t('travelPrefs.intNature'), - gastronomy: t('travelPrefs.intGastronomy'), - gastronomiya: t('travelPrefs.intGastronomy'), - arxitektura: t('travelPrefs.intArchitecture'), - din: t('travelPrefs.intReligious'), - zamonaviy: t('travelPrefs.intModern'), - hunarmandchilik: t('travelPrefs.intCrafts'), - }), - [t] - ); - - const anim = useRef(new Animated.Value(1)).current; - const progress = useRef(new Animated.Value(1 / steps.length)).current; - useEffect(() => { - Animated.timing(progress, { toValue: (step + 1) / steps.length, duration: 250, useNativeDriver: false }).start(); - anim.setValue(0); - Animated.spring(anim, { toValue: 1, useNativeDriver: true, speed: 18, bounciness: 7 }).start(); - }, [anim, progress, step, steps.length]); - - useEffect(() => { - if (!loading) { - setLoadingStageIndex(0); - return; - } - - const timer = setInterval(() => { - setLoadingStageIndex((prev) => (prev + 1) % analysisStages.length); - }, 1100); - - return () => clearInterval(timer); - }, [analysisStages.length, loading]); - - useEffect(() => { - let active = true; - (async () => { - try { - const [cachedPlannerCities, cachedDestinations] = await Promise.all([ - getJSON(KEYS.PLANNER_CITIES_CACHE_V1), - getJSON(KEYS.DESTINATIONS_CACHE_V1), - ]); - - if (!active) return; - - const cachedDestinationNames = Array.isArray(cachedDestinations) - ? getDestinationNamesFromItems(cachedDestinations) - : []; - const mergedCached = mergeCityLists(cachedPlannerCities || [], cachedDestinationNames); - if (mergedCached.length > 0) { - setCities(mergedCached); - } - } catch {} - - try { - const [cityRes, destinationRes] = await Promise.all([ - citiesAPI.getAll().catch(() => null), - destinationsAPI.getAll({ limit: 150 }).catch(() => null), - ]); - const cityPayload = cityRes ? extractApiData(cityRes) : null; - const destinationPayload = destinationRes ? extractApiData(destinationRes) : null; - const coverageItems = Array.isArray(cityPayload?.items) ? cityPayload.items : []; - const items = Array.isArray(destinationPayload?.items) ? destinationPayload.items : []; - const names = mergeCityLists( - coverageItems.map((item: any) => String(item.city || '')), - getDestinationNamesFromItems(items) - ); - const mergedRemote = mergeCityLists(names); - if (active && mergedRemote.length > 0) { - setCities(mergedRemote); - } - if (names.length > 0) { - await saveJSON(KEYS.PLANNER_CITIES_CACHE_V1, names); - } - if (items.length > 0) { - await saveJSON(KEYS.DESTINATIONS_CACHE_V1, items); - } - } catch {} - })(); - return () => { - active = false; - }; - }, []); - - useFocusEffect( - useCallback(() => { - let active = true; - (async () => { - try { - const token = await getItem(KEYS.TOKEN); - if (!active) return; - const hasToken = Boolean(token); - setIsAuthed(hasToken); - setAuthBootstrapDone(true); - } catch { - if (!active) return; - setIsAuthed(false); - setAuthBootstrapDone(true); - } - })(); - return () => { - active = false; - }; - }, []) - ); - - const duration = useMemo(() => getDuration(form.startDate, form.endDate), [form.endDate, form.startDate]); - const budgetRaw = useMemo(() => parseInt(form.budget.replace(/\D/g, ''), 10) || 0, [form.budget]); - const budgetUzs = useMemo(() => (form.currency === 'USD' ? Math.round(budgetRaw * USD_TO_UZS) : budgetRaw), [budgetRaw, form.currency]); - const budgetHelperText = useMemo(() => { - if (budgetRaw <= 0) return '-'; - if (form.currency === 'USD') { - return `~ ${formatSum(budgetUzs)}`; - } - const usd = Math.round(budgetUzs / USD_TO_UZS); - return `~ $${usd} USD`; - }, [budgetRaw, budgetUzs, form.currency]); - const progressWidth = useMemo( - () => progress.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }), - [progress] - ); - const plannerMetrics = useMemo( - () => [ - { label: tt('planner.metricCity', 'Shahar'), value: form.city || '-' }, - { label: tt('planner.metricDays', 'Kun'), value: duration > 0 ? String(duration) : '-' }, - { label: tt('planner.metricBudget', 'Byudjet'), value: budgetUzs > 0 ? formatSum(budgetUzs) : '-' }, - ], - [budgetUzs, duration, form.city, tt] - ); - - const validate = useCallback( - (i: number) => { - if (i === 0) { - if (!form.city.trim()) return Alert.alert(t('planner.errorTitle'), tt('planner.errorCityRequired', 'Enter city.')), false; - if (!parseISO(form.startDate) || !parseISO(form.endDate)) return Alert.alert(t('planner.errorTitle'), tt('planner.errorDateFormat', 'Date format: YYYY-MM-DD')), false; - if (duration <= 0) return Alert.alert(t('planner.errorTitle'), tt('planner.errorEndBeforeStart', 'End date must be after start date.')), false; - if (duration > MAX_DAYS) { - return Alert.alert( - t('planner.errorTitle'), - tt('planner.errorMaxDays', `Maximum ${MAX_DAYS} days.`).replace('{{max}}', String(MAX_DAYS)) - ), false; - } - } - if (i === 2 && budgetUzs < MIN_BUDGET_UZS) return Alert.alert(t('planner.errorTitle'), t('planner.errorMinBudget')), false; - if (i === 3 && form.interests.length === 0) return Alert.alert(t('planner.errorTitle'), t('planner.errorMinInterest')), false; - return true; - }, - [budgetUzs, duration, form.city, form.endDate, form.interests.length, form.startDate, t, tt] - ); - - const onNext = () => validate(step) && setStep((s) => Math.min(s + 1, steps.length - 1)); - const onBack = () => setStep((s) => Math.max(s - 1, 0)); - const applyDatePreset = useCallback( - (days: number) => { - const startDate = parseISO(form.startDate) ? form.startDate : today; - setForm((p) => ({ ...p, startDate, endDate: addDays(startDate, days - 1) })); - }, - [form.startDate, today] - ); - - const onGenerate = async () => { - if (!validate(0) || !validate(2) || !validate(3)) return; - setLoading(true); - setAnalysisSummary(null); - - const tips = [ - form.companions === 'family' - ? tt('planner.tipFamily', 'Family mode: safe and calm places prioritized.') - : form.companions === 'couple' - ? tt('planner.tipCouple', 'Couple mode: romantic evening options added.') - : tt('planner.tipCompanion', 'Companion-based suggestions were added.'), - form.transport === 'cheap' - ? tt('planner.tipTransportCheap', 'Transport: budget options prioritized.') - : form.transport === 'fast' - ? tt('planner.tipTransportFast', 'Transport: fast options prioritized.') - : tt('planner.tipTransportComfort', 'Transport: comfort prioritized.'), - form.food === 'none' - ? tt('planner.tipFoodGeneral', 'Food: general recommendations included.') - : tt('planner.tipFoodPref', 'Food preference: {{food}}.').replace('{{food}}', foodLabels[form.food]), - form.flexibility === 'flexible' - ? tt('planner.tipFlexible', 'Flexible mode: optional activities added.') - : tt('planner.tipFixed', 'Fixed mode: tighter schedule applied.'), - ]; - - const cityName = form.city.trim(); - const departureCity = cityName; - - try { - const poiContextPromise = loadCityPoiPoints(cityName).catch(() => [] as PoiPayload[]); - const cityPoints = await Promise.race([ - poiContextPromise, - new Promise((resolve) => setTimeout(() => resolve([]), POI_CONTEXT_SOFT_TIMEOUT_MS)), - ]); - - const poiContext = buildPoiContextItems(cityName, cityPoints); - const poiInsight = cityPoints.length ? buildPoiInsight(cityName, cityPoints, form, duration) : null; - - if (poiInsight) { - tips.push( - tt('planner.tipPoiAnalysis', '{{city}} analysis: {{count}} places, estimated daily cost {{cost}}.') - .replace('{{city}}', poiInsight.city) - .replace('{{count}}', String(poiInsight.totalPoi)) - .replace('{{cost}}', formatSum(poiInsight.estimatedDayCost)) - ); - if (poiInsight.recommendedNames.length > 0) { - tips.push( - tt('planner.tipPoiBest', 'Best matching places: {{places}}.') - .replace('{{places}}', poiInsight.recommendedNames.slice(0, 4).join(', ')) - ); - } - if (budgetUzs > 0 && poiInsight.estimatedTripCost > budgetUzs) { - tips.push( - tt('planner.tipOverBudget', 'Warning: estimated cost is above budget ({{cost}}).') - .replace('{{cost}}', formatSum(poiInsight.estimatedTripCost)) - ); - } - setAnalysisSummary( - tt('planner.analysisSummary', '{{city}}: {{count}} places analyzed, estimated total {{cost}}.') - .replace('{{city}}', poiInsight.city) - .replace('{{count}}', String(poiInsight.totalPoi)) - .replace('{{cost}}', formatSum(poiInsight.estimatedTripCost)) - ); - } - - const payload = buildPlannerPayload( - form, - cityName, - departureCity, - duration, - budgetUzs, - poiInsight, - poiContext - ); - const response = await plannerAPI.generate(payload); - - const plan = normalizePlan( - response?.data ?? response, - form, - duration, - Array.from(new Set(tips)).slice(0, 10), - normalizePlanLabels, - { - status: 'final', - source: 'backend', - } - ); - await saveJSON(KEYS.CURRENT_PLAN, plan); - setStep(0); - router.push('/planner-result' as any); - } catch (err: any) { - const errorDetail = - getErrorMessage(err) || - tt('planner.generateErrorDefault', 'Serverdan javob kelmadi. Internet va backendni tekshirib qayta urinib ko`ring.'); - Alert.alert( - t('planner.errorTitle'), - tt('planner.generateErrorWithReason', 'Reja yaratilmadi: {{error}}').replace('{{error}}', errorDetail) - ); - } finally { - setLoading(false); - } - }; - - const renderAgencyTourCard = (tour: HomeTourItem) => { - const cardContent = ( - <> - - - {tour.badge} - - - {tour.rating.toFixed(1)} - - - - - - {tour.duration || 'Tour'} - - {tour.title} - {tour.subtitle || tour.city} - - - {tour.agency?.name || tour.city || 'Agency'} - {tour.price || 'Narx so‘rovda'} - - openAgencyTour(tour)} activeOpacity={0.84}> - Batafsil - - - - - ); - - return ( - openAgencyTour(tour)}> - {tour.imageUrl ? ( - - {cardContent} - - ) : ( - - {cardContent} - - )} - - ); - }; - - if (!authBootstrapDone) { - return ( - - - {t('common.loading')} - - ); - } - - if (!isAuthed) { - return ( - - - {t('planner.authTitle')} - {t('planner.authSub')} - - router.push('/login' as any)}> - {t('planner.login')} - - router.push('/register' as any)}> - {t('planner.register')} - - - - ); - } - - return ( - - - router.push('/side-menu' as any)} activeOpacity={0.82}> - - - TravelorAI - router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> - - - - - - Planner & Tours - Discover guided experiences or craft your own adventure. - - - - setPlannerView('tours')} - activeOpacity={0.84} - > - - - - Agentlik Turlari - - setPlannerView('builder')} - activeOpacity={0.84} - > - - - - Mening Rejalarim - - - - {plannerView === 'tours' ? ( - <> - - - Agentlik turlari - router.push('/home-tours' as any)} activeOpacity={0.82}> - Barchasi → - - - - {agencyToursLoading ? ( - - - Agentlik turlari yuklanmoqda... - - ) : agencyTours.length > 0 ? ( - agencyTours.map(renderAgencyTourCard) - ) : ( - - - Hali agentlik tourlari yo‘q - Admin tasdiqlagan tourlar shu yerda avtomatik ko‘rinadi. - - )} - - - - setActionMenuOpen(true)} activeOpacity={0.86}> - - - - ) : ( - <> - - - - - - - AI Concierge - - {step + 1}/{steps.length} - - {t('planner.title')} - {steps[step]} - - {plannerMetrics.map((item) => ( - - {item.value} - {item.label} - - ))} - - - - - {steps.map((item, index) => ( - { - if (index <= step || validate(step)) setStep(index); - }} - > - {index + 1} - - ))} - - - {analysisSummary ? ( - - - {analysisSummary} - - ) : null} - - - - - - - {tt('planner.stepKicker', 'Reja bosqichi')} {step + 1} - {steps[step]} - - - {step === 0 ? ( - <> - {tt('planner.countryFixed', 'Global destinations')} - setForm((p) => ({ ...p, city: v }))} - placeholder={tt('planner.cityPlaceholder', 'City')} - placeholderTextColor={colors.textMuted} - /> - {cities.slice(0, 12).map((c) => setForm((p) => ({ ...p, city: c }))}>{c})} - - setForm((p) => ({ ...p, startDate: v }))} - placeholder="YYYY-MM-DD" - placeholderTextColor={colors.textMuted} - /> - setForm((p) => ({ ...p, endDate: v }))} - placeholder="YYYY-MM-DD" - placeholderTextColor={colors.textMuted} - /> - - - {datePresets.map((preset) => ( - applyDatePreset(preset.days)}> - {preset.label} - - ))} - - - {duration > 0 - ? tt('planner.durationValue', '{{days}} days').replace('{{days}}', String(duration)) - : tt('planner.invalidDateFormat', 'Check date format')} - - - ) : null} - - {step === 1 ? ( - <> - - {(['solo', 'friends', 'family', 'couple'] as Companions[]).map((c) => ( - setForm((p) => ({ ...p, companions: c, travelers: c === 'solo' ? 1 : c === 'couple' && p.travelers < 2 ? 2 : p.travelers }))}> - {companionLabels[c]} - - ))} - - setForm((p) => ({ ...p, travelers: Math.max(form.companions === 'couple' ? 2 : 1, p.travelers - 1) }))}>-{form.travelers} setForm((p) => ({ ...p, travelers: Math.min(8, p.travelers + 1) }))}>+ - - ) : null} - - {step === 2 ? ( - <> - {(['UZS', 'USD'] as Currency[]).map((c) => setForm((p) => ({ ...p, currency: c }))}>{c})} - setForm((p) => ({ ...p, budget: v.replace(/\D/g, '').slice(0, 9) }))} keyboardType="numeric" placeholder={tt('planner.budgetPlaceholderShort', 'Budget')} placeholderTextColor={colors.textMuted} />{form.currency} - {budgetHelperText} - {(form.currency === 'USD' ? [50, 100, 200] : [500000, 1000000, 2000000]).map((n) => setForm((p) => ({ ...p, budget: String(n) }))}>{form.currency === 'USD' ? `$${n}` : formatSum(n)})} - {TRAVEL_STYLE_OPTIONS.map((s) => setForm((p) => ({ ...p, style: s.key }))}>{styleLabels[s.key]})} - - ) : null} - - {step === 3 ? ( - - {INTEREST_OPTIONS.map((it) => { - const active = form.interests.includes(it.key); - return setForm((p) => ({ ...p, interests: active ? p.interests.filter((x) => x !== it.key) : [...p.interests, it.key] }))}>{interestLabels[it.key] || it.key}; - })} - - ) : null} - - {step === 4 ? ( - <> - {tt('planner.foodLabelShort', 'Food')} - {(['halal', 'vegetarian', 'vegan', 'none'] as FoodPref[]).map((x) => setForm((p) => ({ ...p, food: x }))}>{foodLabels[x]})} - {tt('planner.transportLabelShort', 'Transport')} - {(['cheap', 'fast', 'comfort'] as TransportPref[]).map((x) => setForm((p) => ({ ...p, transport: x }))}>{transportLabels[x]})} - - ) : null} - - {step === 5 ? ( - <> - {(['fixed', 'flexible'] as Flexibility[]).map((x) => setForm((p) => ({ ...p, flexibility: x }))}>{flexibilityLabels[x]})} - {`${tt('planner.summaryCity', 'City')}: ${form.city}\n${tt('planner.summaryDates', 'Dates')}: ${form.startDate} - ${form.endDate}\n${tt('planner.summaryCompanions', 'Companions')}: ${companionLabels[form.companions]}\n${tt('planner.summaryBudget', 'Budget')}: ${form.budget || 0} ${form.currency}\n${tt('planner.summaryStyle', 'Style')}: ${styleLabels[form.style]}\n${tt('planner.summaryInterests', 'Interests')}: ${form.interests.map((item) => interestLabels[item] || item).join(', ')}`} - - ) : null} - - - - - {step > 0 ? {t('planner.back')} : null} - {step < steps.length - 1 ? t('planner.next') : loading ? t('planner.generating') : tt('planner.generateBtn', 'AI Reja yaratish')} - - - )} - - {loading && ( - - - - {tt('planner.loadingTitle', 'Creating AI trip plan')} - {analysisStages[loadingStageIndex] || analysisStages[0]} - - {tt('planner.loadingSub', 'Places, prices, transport and food suggestions are being analyzed together...')} - - - - )} - - {actionMenuOpen && ( - - setActionMenuOpen(false)} /> - - - Trip yaratish - - - - - - AI bilan reja - - - - - setActionMenuOpen(false)} activeOpacity={0.86}> - Yopish - - - - - - - )} - - ); -} - -function createStyles(colors: AppColors) { - return StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background }, - bootWrap: { - flex: 1, - backgroundColor: colors.background, - alignItems: 'center', - justifyContent: 'center', - gap: SPACING.sm, - }, - bootTxt: { fontFamily: FONTS.medium, fontSize: 13, color: colors.textSecondary }, - topBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.xs, - paddingBottom: SPACING.md, - }, - iconButton: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.surface, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.06, - shadowRadius: 10, - elevation: 2, - }, - brand: { - fontFamily: FONTS.display, - fontSize: 13, - color: colors.text, - }, - pageIntro: { - paddingHorizontal: SPACING.lg, - marginBottom: SPACING.md, - }, - pageTitle: { - fontFamily: FONTS.display, - fontSize: 24, - color: colors.text, - marginBottom: 4, - }, - pageSubtitle: { - maxWidth: 300, - fontFamily: FONTS.regular, - fontSize: 13, - lineHeight: 18, - color: colors.textMuted, - }, - segmentWrap: { - flexDirection: 'row', - gap: SPACING.md, - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - }, - segmentBtn: { - flex: 1, - minHeight: 118, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - gap: SPACING.sm, - paddingHorizontal: SPACING.sm, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.05, - shadowRadius: 14, - elevation: 2, - }, - segmentBtnActive: { - backgroundColor: colors.surface, - borderColor: colors.border, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.08, - shadowRadius: 18, - elevation: 3, - }, - segmentIconCircle: { - width: 48, - height: 48, - borderRadius: 24, - alignItems: 'center', - justifyContent: 'center', - }, - segmentIconCircleActive: { - backgroundColor: 'rgba(33, 220, 143, 0.34)', - }, - segmentIconCircleMuted: { - backgroundColor: 'rgba(99, 132, 255, 0.16)', - }, - segmentTxt: { - fontFamily: FONTS.semibold, - fontSize: 14, - color: colors.text, - textAlign: 'center', - }, - segmentTxtActive: { - color: colors.text, - }, - toursScroll: { flex: 1 }, - toursHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: SPACING.lg, - marginBottom: SPACING.md, - }, - toursTitle: { - fontFamily: FONTS.display, - fontSize: 18, - color: colors.text, - }, - seeAll: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.success, - }, - tourCard: { - height: 312, - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - borderRadius: 24, - overflow: 'hidden', - backgroundColor: colors.primary, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 14 }, - shadowOpacity: 0.16, - shadowRadius: 24, - elevation: 7, - }, - tourImage: { flex: 1, justifyContent: 'space-between' }, - tourImageRadius: { borderRadius: 24 }, - tourImagePlaceholder: { borderRadius: 24, backgroundColor: colors.primary }, - tourScrim: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(4,10,18,0.34)', - }, - toursStateCard: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - borderRadius: 24, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.lg, - alignItems: 'center', - gap: SPACING.sm, - }, - toursStateTitle: { - fontFamily: FONTS.display, - fontSize: 18, - color: colors.text, - textAlign: 'center', - }, - toursStateText: { - fontFamily: FONTS.regular, - fontSize: 12, - lineHeight: 18, - color: colors.textMuted, - textAlign: 'center', - }, - tourTopRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: SPACING.md, - }, - tourBadge: { - borderRadius: 5, - backgroundColor: 'rgba(255,255,255,0.24)', - paddingHorizontal: 7, - paddingVertical: 4, - }, - tourBadgeTxt: { - fontFamily: FONTS.semibold, - fontSize: 9, - letterSpacing: 0.7, - color: colors.textInverse, - }, - tourRating: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - borderRadius: RADIUS.full, - backgroundColor: colors.surface, - paddingHorizontal: 9, - paddingVertical: 5, - }, - tourRatingTxt: { - fontFamily: FONTS.semibold, - fontSize: 10, - color: colors.text, - }, - tourContent: { padding: SPACING.md }, - tourDays: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - marginBottom: 4, - }, - tourDaysTxt: { - fontFamily: FONTS.medium, - fontSize: 10, - color: 'rgba(255,255,255,0.82)', - }, - tourTitle: { - fontFamily: FONTS.display, - fontSize: 30, - lineHeight: 34, - color: colors.textInverse, - }, - tourSub: { - marginTop: 2, - maxWidth: 290, - fontFamily: FONTS.regular, - fontSize: 12, - lineHeight: 17, - color: 'rgba(255,255,255,0.86)', - }, - tourFooter: { - marginTop: SPACING.md, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - fromTxt: { - fontFamily: FONTS.regular, - fontSize: 10, - color: 'rgba(255,255,255,0.72)', - }, - tourPrice: { - fontFamily: FONTS.display, - fontSize: 20, - color: colors.textInverse, - }, - detailsBtn: { - minWidth: 118, - height: 36, - borderRadius: 7, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.success, - }, - detailsBtnTxt: { - fontFamily: FONTS.semibold, - fontSize: 11, - color: colors.textInverse, - }, - fab: { - position: 'absolute', - right: SPACING.lg, - width: 50, - height: 50, - borderRadius: 25, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#000', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.22, - shadowRadius: 20, - elevation: 10, - }, - quickOverlay: { - ...StyleSheet.absoluteFillObject, - zIndex: 60, - }, - quickDismiss: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.68)', - }, - quickActions: { - position: 'absolute', - right: SPACING.lg, - gap: SPACING.lg, - alignItems: 'flex-end', - }, - quickActionRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - gap: SPACING.md, - }, - quickActionText: { - fontFamily: FONTS.display, - fontSize: 22, - color: '#fff', - textAlign: 'right', - textShadowColor: 'rgba(0,0,0,0.35)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 6, - }, - quickActionIcon: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 18 }, - shadowOpacity: 0.24, - shadowRadius: 28, - elevation: 14, - }, - header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: SPACING.lg, paddingVertical: SPACING.md }, - hero: { - marginHorizontal: SPACING.lg, - marginTop: SPACING.sm, - marginBottom: SPACING.md, - padding: SPACING.xl, - borderRadius: RADIUS.xxl, - backgroundColor: colors.primary, - overflow: 'hidden', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 18 }, - shadowOpacity: 0.22, - shadowRadius: 32, - elevation: 10, - }, - heroOrbOne: { - position: 'absolute', - width: 170, - height: 170, - borderRadius: 85, - right: -60, - top: -54, - backgroundColor: 'rgba(104,219,169,0.18)', - }, - heroOrbTwo: { - position: 'absolute', - width: 118, - height: 118, - borderRadius: 59, - left: -34, - bottom: -50, - backgroundColor: 'rgba(222,194,154,0.16)', - }, - heroTopRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.lg }, - heroBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingHorizontal: SPACING.md, - paddingVertical: 7, - borderRadius: RADIUS.full, - backgroundColor: 'rgba(255,255,255,0.10)', - }, - heroBadgeTxt: { fontFamily: FONTS.medium, fontSize: 11, color: 'rgba(255,255,255,0.84)' }, - title: { fontFamily: FONTS.display, fontSize: 31, lineHeight: 38, color: colors.textInverse }, - stepTxt: { - fontFamily: FONTS.medium, - fontSize: 12, - color: colors.textInverse, - paddingHorizontal: SPACING.sm, - paddingVertical: 6, - borderRadius: RADIUS.full, - backgroundColor: 'rgba(255,255,255,0.12)', - overflow: 'hidden', - }, - subtitle: { fontFamily: FONTS.regular, fontSize: 14, color: 'rgba(255,255,255,0.78)', marginTop: SPACING.sm, lineHeight: 21 }, - heroMetrics: { flexDirection: 'row', gap: SPACING.sm, marginTop: SPACING.xl, marginBottom: SPACING.md }, - heroMetric: { - flex: 1, - borderRadius: RADIUS.lg, - backgroundColor: 'rgba(255,255,255,0.10)', - paddingHorizontal: SPACING.sm, - paddingVertical: SPACING.md, - }, - heroMetricValue: { fontFamily: FONTS.semibold, fontSize: 13, color: colors.textInverse }, - heroMetricLabel: { marginTop: 3, fontFamily: FONTS.regular, fontSize: 10, color: 'rgba(255,255,255,0.62)' }, - progressTrack: { height: 6, backgroundColor: 'rgba(255,255,255,0.18)', borderRadius: RADIUS.full, overflow: 'hidden' }, - progressFill: { height: 6, backgroundColor: colors.success, borderRadius: RADIUS.full }, - stepRail: { flexDirection: 'row', gap: SPACING.sm, paddingHorizontal: SPACING.lg, marginBottom: SPACING.md }, - stepDot: { - flex: 1, - height: 34, - borderRadius: RADIUS.md, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.06, - shadowRadius: 12, - elevation: 2, - }, - stepDotActive: { backgroundColor: colors.primary }, - stepDotDone: { backgroundColor: colors.success }, - stepDotText: { fontFamily: FONTS.medium, fontSize: 12, color: colors.textMuted }, - stepDotTextActive: { color: colors.textInverse }, - prefill: { marginHorizontal: SPACING.lg, marginBottom: SPACING.sm, marginTop: SPACING.sm, padding: SPACING.md, borderRadius: RADIUS.md, borderWidth: 1, borderColor: colors.borderLight, backgroundColor: colors.surface, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - prefillTxt: { flex: 1, fontFamily: FONTS.regular, fontSize: 12, color: colors.textSecondary }, - analysisSummaryBox: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.sm, - padding: SPACING.md, - borderRadius: RADIUS.lg, - backgroundColor: colors.infoPale, - flexDirection: 'row', - alignItems: 'flex-start', - gap: SPACING.sm, - }, - analysisSummaryTxt: { - fontFamily: FONTS.regular, - fontSize: 12, - color: colors.info, - lineHeight: 18, - }, - link: { fontFamily: FONTS.semibold, color: colors.primary, fontSize: 12 }, - scroll: { flex: 1 }, - card: { - marginHorizontal: SPACING.lg, - padding: SPACING.lg, - borderRadius: RADIUS.xxl, - backgroundColor: colors.surface, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.1, - shadowRadius: 24, - elevation: 4, - }, - cardHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, marginBottom: SPACING.lg }, - cardIcon: { - width: 50, - height: 50, - borderRadius: RADIUS.lg, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primaryPale, - }, - cardHeaderCopy: { flex: 1 }, - cardKicker: { - fontFamily: FONTS.medium, - fontSize: 11, - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.9, - marginBottom: 3, - }, - cardTitle: { fontFamily: FONTS.display, fontSize: 20, color: colors.text }, - input: { height: 50, borderRadius: RADIUS.lg, backgroundColor: colors.cardMuted, paddingHorizontal: SPACING.md, color: colors.text, fontFamily: FONTS.medium, marginBottom: SPACING.sm }, - row: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm }, - flex: { flex: 1 }, - rowWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm, marginBottom: SPACING.sm }, - label: { fontFamily: FONTS.medium, fontSize: 12, color: colors.textMuted, marginBottom: 6 }, - small: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textSecondary, lineHeight: 18, marginTop: SPACING.xs }, - chip: { borderRadius: RADIUS.md, backgroundColor: colors.cardMuted, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm }, - chipActive: { backgroundColor: colors.primary }, - chipTxt: { fontFamily: FONTS.medium, fontSize: 12, color: colors.textSecondary }, - chipTxtActive: { color: colors.textInverse }, - quick: { borderRadius: RADIUS.md, backgroundColor: colors.primaryPale, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm }, - quickTxt: { fontFamily: FONTS.medium, fontSize: 12, color: colors.primary }, - currency: { fontFamily: FONTS.semibold, color: colors.textSecondary, marginBottom: SPACING.sm }, - stepper: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: SPACING.md, marginTop: SPACING.md }, - stepBtn: { width: 38, height: 38, borderRadius: RADIUS.full, backgroundColor: colors.primaryPale, alignItems: 'center', justifyContent: 'center' }, - stepBtnTxt: { fontFamily: FONTS.semibold, fontSize: 20, color: colors.primary }, - stepVal: { minWidth: 36, textAlign: 'center', fontFamily: FONTS.semibold, fontSize: 22, color: colors.text }, - footer: { - flexDirection: 'row', - gap: SPACING.sm, - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.md, - backgroundColor: colors.glassStrong, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: -10 }, - shadowOpacity: 0.1, - shadowRadius: 22, - elevation: 12, - }, - primaryBtn: { flex: 2, height: 52, borderRadius: RADIUS.md, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, - primaryBtnTxt: { fontFamily: FONTS.semibold, fontSize: 15, color: '#fff' }, - outlineBtn: { flex: 1, height: 52, borderRadius: RADIUS.md, backgroundColor: colors.primaryPale, alignItems: 'center', justifyContent: 'center' }, - outlineBtnTxt: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.primary }, - full: { flex: 1 }, - authGate: { flex: 1, backgroundColor: colors.background, alignItems: 'center', justifyContent: 'center', paddingHorizontal: SPACING.xl }, - authIconWrap: { width: 88, height: 88, borderRadius: 44, backgroundColor: colors.primaryPale, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.xl }, - authTitle: { fontFamily: FONTS.display, fontSize: 24, color: colors.text, textAlign: 'center', marginBottom: SPACING.sm }, - authSub: { fontFamily: FONTS.regular, fontSize: 14, color: colors.textSecondary, textAlign: 'center', lineHeight: 22, marginBottom: SPACING.xl }, - authActions: { width: '100%', maxWidth: 360, gap: SPACING.md }, - authPrimaryBtn: { - width: '100%', - height: 54, - borderRadius: RADIUS.full, - backgroundColor: colors.primary, - alignItems: 'center', - justifyContent: 'center', - }, - authPrimaryBtnTxt: { fontFamily: FONTS.semibold, fontSize: 17, color: '#fff' }, - authOutlineBtn: { - width: '100%', - height: 54, - borderRadius: RADIUS.full, - borderWidth: 1.5, - borderColor: colors.primary, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.surface, - }, - authOutlineBtnTxt: { fontFamily: FONTS.semibold, fontSize: 17, color: colors.primary }, - loadingOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.38)', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SPACING.lg, - zIndex: 99, - }, - loadingCard: { - width: '100%', - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.borderLight, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.xl, - gap: SPACING.sm, - }, - loadingTitle: { - marginTop: SPACING.sm, - fontFamily: FONTS.semibold, - fontSize: 16, - color: colors.text, - textAlign: 'center', - }, - loadingStage: { - fontFamily: FONTS.medium, - fontSize: 13, - color: colors.primary, - textAlign: 'center', - }, - loadingSub: { - fontFamily: FONTS.regular, - fontSize: 12, - color: colors.textSecondary, - textAlign: 'center', - lineHeight: 18, - }, - }); -} diff --git a/mobile/app/(tabs)/profile.tsx b/mobile/app/(tabs)/profile.tsx index 1c49fd6..48e10ca 100644 --- a/mobile/app/(tabs)/profile.tsx +++ b/mobile/app/(tabs)/profile.tsx @@ -20,10 +20,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FONTS } from '../../src/constants/fonts'; import { RADIUS, SPACING } from '../../src/constants/spacing'; import { type AppColors, type ThemePreference, useAppTheme } from '../../src/theme/app-theme'; -import { useAchievements } from '../../src/hooks/useAchievements'; -import { useTrips } from '../../src/hooks/useTrips'; import { useWishlist } from '../../src/hooks/useWishlist'; -import { authAPI } from '../../src/utils/api'; +import { ApiError, authAPI, type SecurityCodePayload } from '../../src/utils/api'; import { type AuthUser, getUserDisplayName, getUserInitials } from '../../src/utils/auth'; import { extractApiData } from '../../src/utils/auth'; import { KEYS, clearAll, clearAuthSession, getItem, getJSON, getUserKey, saveItem, saveUserProfile } from '../../src/utils/storage'; @@ -47,15 +45,16 @@ export default function ProfileScreen() { const [token, setToken] = useState(null); const [isReady, setIsReady] = useState(false); const userId = user?.id ?? null; - const { trips, loadTrips } = useTrips(userId); - const { achievements, loadAchievements } = useAchievements(trips, userId); - const { wishlist, remove: removeWishlist } = useWishlist(userId); + const { wishlist } = useWishlist(userId); const [offline, setOffline] = useState(false); const [refreshing, setRefreshing] = useState(false); const [language, setLanguage] = useState('uz'); const [showLangPicker, setShowLangPicker] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deletePassword, setDeletePassword] = useState(''); + const [deleteCode, setDeleteCode] = useState(''); + const [deleteStage, setDeleteStage] = useState<'request' | 'verify'>('request'); + const [deleteAttemptsRemaining, setDeleteAttemptsRemaining] = useState(3); const [isDeletingAccount, setIsDeletingAccount] = useState(false); const syncUserFromServer = useCallback( @@ -88,11 +87,10 @@ export default function ProfileScreen() { setRefreshing(true); try { await syncUserFromServer(user); - await Promise.all([loadTrips(), loadAchievements()]); } finally { setRefreshing(false); } - }, [loadAchievements, loadTrips, syncUserFromServer, token, user, userId]); + }, [syncUserFromServer, token, user, userId]); useFocusEffect( useCallback(() => { @@ -129,16 +127,6 @@ export default function ProfileScreen() { }, [i18n, syncUserFromServer]) ); - useFocusEffect( - useCallback(() => { - if (!userId) return; - - (async () => { - await Promise.all([loadTrips(), loadAchievements()]); - })(); - }, [loadAchievements, loadTrips, userId]) - ); - const isLoggedIn = Boolean(token); if (!isReady) { @@ -174,45 +162,10 @@ export default function ProfileScreen() { ); } - const localTripCount = trips.length; - const localTotalSpent = trips.reduce((sum, trip) => sum + (trip.totalCost || 0), 0); - const totalDays = trips.reduce((sum, trip) => sum + (trip.duration || 0), 0); - const allDestinations = trips.flatMap((trip) => trip.destinations || []); - const localCities = [...new Set(allDestinations)].length; - const remoteStats = achievements?.stats; - const tripCount = typeof remoteStats?.tripCount === 'number' ? remoteStats.tripCount : localTripCount; - const cityCount = typeof remoteStats?.uniqueCities === 'number' ? remoteStats.uniqueCities : localCities; - const totalSpent = typeof remoteStats?.totalSpent === 'number' ? remoteStats.totalSpent : localTotalSpent; - const avgCost = tripCount > 0 ? Math.round(totalSpent / tripCount) : 0; - const frequencyMap: Record = {}; - allDestinations.forEach((destination) => { - frequencyMap[destination] = (frequencyMap[destination] || 0) + 1; - }); - const mostVisited = Object.keys(frequencyMap).sort((a, b) => frequencyMap[b] - frequencyMap[a])[0] ?? null; - const achievementPreview = - achievements.unlocked.length > 0 ? achievements.unlocked.slice(0, 3) : achievements.locked.slice(0, 3); - const lastTrip = - trips.length > 0 - ? trips.slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] - : null; const wishlistCount = wishlist.length; - const achievementProgress = Math.round(achievements.completionRate * 100); - const name = user ? getUserDisplayName(user) : t('profile.guestName'); const initials = getUserInitials(user); - const formatMoney = (value: number) => { - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1)}M`; - } - - if (value >= 1_000) { - return `${Math.round(value / 1_000)}K`; - } - - return String(value); - }; - // ── Notification toggle ─────────────────────────────────────────────────── // ── Offline mode toggle ─────────────────────────────────────────────────── const handleOfflineToggle = async (value: boolean) => { @@ -277,6 +230,9 @@ export default function ProfileScreen() { const openDeleteAccountModal = () => { setDeletePassword(''); + setDeleteCode(''); + setDeleteStage('request'); + setDeleteAttemptsRemaining(3); setDeleteModalVisible(true); }; @@ -284,9 +240,11 @@ export default function ProfileScreen() { if (isDeletingAccount) return; setDeleteModalVisible(false); setDeletePassword(''); + setDeleteCode(''); + setDeleteStage('request'); }; - const submitDeleteAccount = async () => { + const requestDeleteCode = async () => { if (!user) return; if (user.authProvider === 'local' && deletePassword.trim().length === 0) { @@ -296,10 +254,35 @@ export default function ProfileScreen() { setIsDeletingAccount(true); try { - await authAPI.deleteAccount({ - confirm: true, + const data = extractApiData(await authAPI.requestAccountDeletion({ ...(user.authProvider === 'local' ? { password: deletePassword.trim() } : {}), - }); + })); + setDeleteStage('verify'); + setDeleteAttemptsRemaining(data.attemptsRemaining); + if (data.devCode) setDeleteCode(data.devCode); + Alert.alert(t('common.ok'), data.devCode ? `${data.message}\nKod: ${data.devCode}` : data.message); + } catch (e: any) { + const apiError = e instanceof ApiError ? e : null; + if (apiError?.data?.contactAdmin) { + setDeleteModalVisible(false); + Alert.alert(t('profile.errorTitle'), apiError.message); + } else { + Alert.alert(t('profile.errorTitle'), apiError?.message || t('profile.deleteErrorMsg')); + } + } finally { + setIsDeletingAccount(false); + } + }; + + const submitDeleteAccount = async () => { + if (deleteCode.trim().length !== 6) { + Alert.alert(t('profile.errorTitle'), 'Emailga kelgan 6 xonali kodni kiriting.'); + return; + } + + setIsDeletingAccount(true); + try { + await authAPI.deleteAccount({ confirm: true, code: deleteCode.trim() }); await clearAll(); setDeleteModalVisible(false); Alert.alert(t('profile.deleteSuccessTitle'), t('profile.deleteSuccessMsg')); @@ -315,56 +298,15 @@ export default function ProfileScreen() { } }; - const menu = [ - { - icon: 'chatbubble-ellipses-outline' as const, - label: t('profile.feedback', { defaultValue: 'Fikr va shikoyatlar' }), - onPress: () => router.push('/feedback'), - }, - { - icon: 'language-outline' as const, - label: 'Tilni tanlash', - onPress: () => router.push('/language' as any), - }, - { - icon: 'card-outline' as const, - label: 'To‘lov usullari', - onPress: () => router.push('/payment-methods' as any), - }, - { - icon: 'gift-outline' as const, - label: 'Aksiyalar', - onPress: () => router.push('/promotions' as any), - }, - { - icon: 'help-circle-outline' as const, - label: 'Yordam markazi', - onPress: () => router.push('/help-center' as any), - }, - { - icon: 'settings-outline' as const, - label: t('profile.settings'), - onPress: () => router.push('/settings'), - }, - ]; - const quickActions = [ { - key: 'stats', - icon: 'stats-chart-outline' as const, - title: t('profile.stats'), - subtitle: `${tripCount} ${t('profile.trips')} · ${cityCount} ${t('profile.cities')}`, - onPress: () => router.push('/profile-stats'), + key: 'bookings', + icon: 'briefcase-outline' as const, + title: 'Mening bronlarim', + subtitle: 'Sotib olingan turlar', + onPress: () => router.push('/bookings' as any), badge: null as number | null, }, - { - key: 'achievements', - icon: 'trophy-outline' as const, - title: t('profile.achievements'), - subtitle: `${achievements.unlockedCount}/${achievements.totalCount} ${t('achievements.unlocked')} · ${achievementProgress}%`, - onPress: () => router.push('/achievements'), - badge: achievements.unlockedCount, - }, { key: 'wishlist', icon: 'heart-outline' as const, @@ -416,22 +358,6 @@ export default function ProfileScreen() { {name} {user?.email ? {user.email} : null} {user?.bio ? {user.bio} : null} - - - {tripCount} - {t('profile.trips')} - - - - {cityCount} - {t('profile.cities')} - - - - {achievementProgress}% - {t('achievements.progressLabel')} - - @@ -459,192 +385,6 @@ export default function ProfileScreen() { - - {t('profile.stats')} - - - {([ - { icon: 'map-outline', value: String(tripCount), label: t('profile.trips'), accent: false }, - { icon: 'location-outline', value: String(cityCount), label: t('profile.cities'), accent: false }, - { icon: 'wallet-outline', value: formatMoney(totalSpent), label: t('profile.totalSpent'), accent: true }, - ] as const).map((item) => ( - - - - - {item.value} - {item.label} - - ))} - - - - - - {totalDays} - {t('profile.days')} - - - - - {formatMoney(avgCost)} - {t('profile.avgTrip')} - - - - - {mostVisited ?? '-'} - {t('profile.mostVisited')} - - - - {lastTrip ? ( - - - - - - - {t('profile.lastTrip')} - - {lastTrip.title} - - - {lastTrip.duration} {t('common.days')} | {lastTrip.destinations?.join(', ') || '-'} - - - - - {formatMoney(lastTrip.totalCost)} - {t('common.som')} - - - ) : ( - - - {t('profile.noTrips')} - - )} - - - - - - - {t('profile.achievements')} - {t('achievements.subtitle')} - - router.push('/achievements')} - activeOpacity={0.82} - > - {t('profile.achievementsView')} - - - - - - {achievements.unlockedCount} - {t('achievements.unlocked')} - - - - {achievements.totalCount} - {t('achievements.total')} - - - - {Math.round(achievements.completionRate * 100)}% - {t('achievements.progressLabel')} - - - - - {achievementPreview.map((item) => ( - - - - - - {t(item.title)} - - - {item.unlocked ? t('achievements.unlocked') : item.progressText} - - - ))} - - - {achievements.nextAchievement ? ( - - {t('achievements.nextBadge')} - {t(achievements.nextAchievement.title)} - {t(achievements.nextAchievement.hint)} - - - - - ) : null} - - - - {/* ── Wishlist (Bormoqchi joylar) ── */} - - - {t('profile.wishlist')} - {wishlist.length > 0 && ( - - {wishlist.length} - - )} - - - {wishlist.length === 0 ? ( - - - {t('profile.wishlistEmpty')} - {t('profile.wishlistHint')} - - ) : ( - wishlist.map((item) => ( - - - {item.icon} - - - {item.name} - {item.city} - - removeWishlist(item.id)} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - - - - )) - )} - - @@ -736,19 +476,17 @@ export default function ProfileScreen() { thumbColor={colors.surface} /> - - - {t('profile.other')} - {menu.map((item) => ( - - - - {item.label} + router.push('/settings' as any)} activeOpacity={0.8}> + + + + {t('profile.settings')} + Bildirishnomalar va ilova sozlamalari - - - ))} + + + @@ -765,9 +503,13 @@ export default function ProfileScreen() { {t('profile.deleteModalTitle')} - {t('profile.deleteModalSub')} + + {deleteStage === 'request' + ? 'Avval emailingizga tasdiqlash kodi yuboramiz. Kodni jami 3 marta so‘rashingiz mumkin.' + : `${user?.email} manziliga yuborilgan 6 xonali kodni kiriting. Qolgan so‘rov: ${deleteAttemptsRemaining}.`} + - {user?.authProvider === 'local' ? ( + {deleteStage === 'request' && user?.authProvider === 'local' ? ( <> {t('profile.deletePasswordLabel')} - ) : ( + ) : deleteStage === 'request' ? ( {t('profile.deleteGoogleHint')} + ) : ( + <> + Tasdiqlash kodi + setDeleteCode(value.replace(/\D/g, '').slice(0, 6))} + editable={!isDeletingAccount} + placeholder="000000" + placeholderTextColor={colors.textMuted} + style={styles.modalInput} + keyboardType="number-pad" + maxLength={6} + /> + + + {deleteAttemptsRemaining > 0 ? `Kodni qayta yuborish (${deleteAttemptsRemaining})` : 'Limit tugadi, adminga murojaat qiling'} + + + )} @@ -800,14 +565,16 @@ export default function ProfileScreen() { {isDeletingAccount ? ( ) : ( - {t('profile.deleteNow')} + + {deleteStage === 'request' ? 'Kodni yuborish' : t('profile.deleteNow')} + )} @@ -922,19 +689,6 @@ function createStyles(colors: AppColors, bottomInset: number) { marginTop: SPACING.sm, paddingHorizontal: SPACING.md, }, - heroMetricRow: { - width: '100%', - flexDirection: 'row', - alignItems: 'center', - borderRadius: RADIUS.xl, - backgroundColor: colors.glassStrong, - paddingVertical: SPACING.md, - marginTop: SPACING.lg, - }, - heroMetric: { flex: 1, alignItems: 'center' }, - heroMetricValue: { fontFamily: FONTS.display, fontSize: 19, color: colors.text }, - heroMetricLabel: { marginTop: 2, fontFamily: FONTS.regular, fontSize: 11, color: colors.textMuted }, - heroMetricDivider: { width: 1, height: 36, backgroundColor: colors.borderLight }, guestLabel: { fontFamily: FONTS.regular, fontSize: 13, color: colors.textMuted, fontStyle: 'italic' }, authRow: { flexDirection: 'row', gap: SPACING.md, marginTop: SPACING.lg, width: '100%' }, loginBtn: { @@ -955,128 +709,7 @@ function createStyles(colors: AppColors, bottomInset: number) { borderColor: colors.primary, }, registerTxt: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.primary }, - statsSection: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - backgroundColor: colors.surface, - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.lg, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.08, - shadowRadius: 14, - elevation: 5, - }, - statsSectionTitle: { - fontFamily: FONTS.semibold, - fontSize: 14, - color: colors.textMuted, - marginBottom: SPACING.md, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - statsRow: { flexDirection: 'row', gap: SPACING.sm, marginBottom: SPACING.md }, - stat: { - flex: 1, - alignItems: 'center', - backgroundColor: colors.cardMuted, - borderRadius: RADIUS.lg, - paddingVertical: SPACING.md, - borderWidth: 1, - borderColor: colors.borderLight, - }, - statAccent: { - backgroundColor: colors.primary, - borderColor: colors.primary, - shadowColor: colors.primary, - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.25, - shadowRadius: 12, - elevation: 6, - }, - statIconWrap: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.primaryPale, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 8, - }, - statIconWrapAccent: { backgroundColor: 'rgba(255,255,255,0.2)' }, - statVal: { fontFamily: FONTS.semibold, fontSize: 17, color: colors.text }, - statValAccent: { color: colors.textInverse }, - statLabel: { fontFamily: FONTS.regular, fontSize: 11, color: colors.textMuted, marginTop: 2 }, - statLabelAccent: { color: 'rgba(255,255,255,0.75)' }, - statsRow2: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.cardMuted, - borderRadius: RADIUS.lg, - borderWidth: 1, - borderColor: colors.borderLight, - paddingVertical: SPACING.md, - marginBottom: SPACING.md, - }, - stat2: { flex: 1, alignItems: 'center', gap: 4 }, - stat2Divider: { width: 1, height: 36, backgroundColor: colors.borderLight }, - stat2Val: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.text }, - stat2Label: { fontFamily: FONTS.regular, fontSize: 10, color: colors.textMuted }, - lastTripCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primaryPale, - borderRadius: RADIUS.lg, - borderWidth: 1, - borderColor: `${colors.primary}33`, - padding: SPACING.md, - gap: SPACING.md, - }, - lastTripLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: SPACING.md }, - lastTripBody: { flex: 1 }, - lastTripIconWrap: { - width: 42, - height: 42, - borderRadius: 21, - backgroundColor: colors.primary, - alignItems: 'center', - justifyContent: 'center', - shadowColor: colors.primary, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - }, - lastTripBadge: { - fontFamily: FONTS.medium, - fontSize: 10, - color: colors.primary, - textTransform: 'uppercase', - letterSpacing: 0.4, - marginBottom: 2, - }, - lastTripTitle: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.text }, - lastTripMeta: { fontFamily: FONTS.regular, fontSize: 11, color: colors.textMuted, marginTop: 2 }, - lastTripRight: { alignItems: 'flex-end' }, - lastTripCost: { fontFamily: FONTS.semibold, fontSize: 16, color: colors.primary }, - lastTripCostLabel: { fontFamily: FONTS.regular, fontSize: 10, color: colors.textMuted }, - emptyStats: { alignItems: 'center', paddingVertical: SPACING.lg, gap: SPACING.sm }, - emptyStatsTxt: { fontFamily: FONTS.regular, fontSize: 13, color: colors.textMuted, textAlign: 'center' }, section: { marginHorizontal: SPACING.lg, marginBottom: SPACING.lg }, - hiddenSection: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.lg, - borderRadius: 28, - backgroundColor: colors.surface, - padding: SPACING.lg, - shadowColor: colors.shadow, - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.1, - shadowRadius: 20, - elevation: 5, - }, secTitle: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.text, marginBottom: SPACING.md }, quickActionList: { flexDirection: 'row', @@ -1207,56 +840,6 @@ function createStyles(colors: AppColors, bottomInset: number) { }, achievementProgressFill: { height: '100%', borderRadius: RADIUS.full }, // ── Wishlist ──────────────────────────────────────────────────────────── - wishlistHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: SPACING.md, gap: SPACING.sm }, - wishlistBadge: { - backgroundColor: colors.primary, - borderRadius: RADIUS.full, - minWidth: 22, - height: 22, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 6, - }, - wishlistBadgeTxt: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.textInverse }, - wishlistEmpty: { - backgroundColor: colors.cardMuted, - borderRadius: RADIUS.lg, - paddingVertical: SPACING.xl, - alignItems: 'center', - gap: SPACING.sm, - }, - wishlistEmptyIcon: { fontSize: 32 }, - wishlistEmptyTxt: { fontFamily: FONTS.medium, fontSize: 14, color: colors.textSecondary }, - wishlistEmptyHint: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textMuted }, - wishRow: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.cardMuted, - borderRadius: RADIUS.lg, - padding: SPACING.md, - marginBottom: SPACING.sm, - gap: SPACING.md, - }, - wishIconWrap: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.primaryPale, - alignItems: 'center', - justifyContent: 'center', - }, - wishIcon: { fontSize: 20 }, - wishInfo: { flex: 1 }, - wishName: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.text }, - wishCity: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textMuted, marginTop: 2 }, - wishRemoveBtn: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.errorPale, - alignItems: 'center', - justifyContent: 'center', - }, themeCard: { backgroundColor: colors.surface, borderRadius: RADIUS.xl, @@ -1346,18 +929,6 @@ function createStyles(colors: AppColors, bottomInset: number) { langFlag: { fontSize: 22 }, langLabel: { fontFamily: FONTS.medium, fontSize: 14, color: colors.text }, langLabelActive: { color: colors.primary }, - menuRow: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.surface, - borderRadius: RADIUS.md, - padding: SPACING.md, - marginBottom: SPACING.sm, - borderWidth: 1, - borderColor: colors.borderLight, - justifyContent: 'space-between', - }, - menuLabel: { fontFamily: FONTS.medium, fontSize: 14, color: colors.text }, logoutBtn: { marginHorizontal: SPACING.lg, backgroundColor: colors.errorPale, diff --git a/mobile/app/(tabs)/tours.tsx b/mobile/app/(tabs)/tours.tsx new file mode 100644 index 0000000..b5067f7 --- /dev/null +++ b/mobile/app/(tabs)/tours.tsx @@ -0,0 +1 @@ +export { default } from '../home-tours'; diff --git a/mobile/app/(tabs)/trips.tsx b/mobile/app/(tabs)/trips.tsx index 3c16392..f639a06 100644 --- a/mobile/app/(tabs)/trips.tsx +++ b/mobile/app/(tabs)/trips.tsx @@ -1,3 +1 @@ -import { MyPlansScreen } from '../../src/components/planner/TripManagementScreens'; - -export default MyPlansScreen; +export { default } from '../bookings'; diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 832b4c8..5060ab0 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,9 +4,9 @@ import { StatusBar } from 'expo-status-bar'; import { ThemeProvider } from '@react-navigation/native'; import { useFonts, - PlusJakartaSans_600SemiBold, - PlusJakartaSans_700Bold, -} from '@expo-google-fonts/plus-jakarta-sans'; + SpaceGrotesk_600SemiBold, + SpaceGrotesk_700Bold, +} from '@expo-google-fonts/space-grotesk'; import { Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter'; import * as SplashScreen from 'expo-splash-screen'; import 'react-native-reanimated'; @@ -24,8 +24,8 @@ export default function RootLayout() { const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold, - PlusJakartaSans_600SemiBold, - PlusJakartaSans_700Bold, + SpaceGrotesk_600SemiBold, + SpaceGrotesk_700Bold, }); useEffect(() => { @@ -80,8 +80,8 @@ function RootNavigator() { fonts: { regular: { fontFamily: 'Inter_400Regular', fontWeight: '400' }, medium: { fontFamily: 'Inter_600SemiBold', fontWeight: '600' }, - bold: { fontFamily: 'PlusJakartaSans_700Bold', fontWeight: '700' }, - heavy: { fontFamily: 'PlusJakartaSans_700Bold', fontWeight: '700' }, + bold: { fontFamily: 'SpaceGrotesk_700Bold', fontWeight: '700' }, + heavy: { fontFamily: 'SpaceGrotesk_700Bold', fontWeight: '700' }, }, }} > @@ -96,40 +96,22 @@ function RootNavigator() { - - - - - - - - + - - + + + + + - - - - - - - - - - - - - - diff --git a/mobile/app/achievements.tsx b/mobile/app/achievements.tsx deleted file mode 100644 index 608f064..0000000 --- a/mobile/app/achievements.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router, useFocusEffect } from 'expo-router'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTranslation } from 'react-i18next'; - -import { FONTS } from '../src/constants/fonts'; -import { RADIUS, SPACING } from '../src/constants/spacing'; -import { useAchievements } from '../src/hooks/useAchievements'; -import { useTrips } from '../src/hooks/useTrips'; -import { type AppColors, useAppTheme } from '../src/theme/app-theme'; -import { type AuthUser } from '../src/utils/auth'; -import { KEYS, getJSON } from '../src/utils/storage'; - -export default function AchievementsScreen() { - const insets = useSafeAreaInsets(); - const { colors } = useAppTheme(); - const { t } = useTranslation(); - const styles = createStyles(colors); - const [userId, setUserId] = useState(null); - - useEffect(() => { - getJSON(KEYS.USER).then((u) => setUserId(u?.id ?? null)); - }, []); - - const { trips, loadTrips } = useTrips(userId); - const { achievements, loadAchievements } = useAchievements(trips, userId); - - useFocusEffect( - useCallback(() => { - loadTrips(); - loadAchievements(); - }, [loadAchievements, loadTrips]) - ); - - return ( - - - router.back()} activeOpacity={0.8}> - - {t('achievements.backBtn')} - - - {Math.round(achievements.completionRate * 100)}% - - - - - - - {t('achievements.heading')} - {t('achievements.title')} - {t('achievements.subtitle')} - - - - {achievements.unlockedCount} - {t('achievements.unlocked')} - - - - {achievements.totalCount} - {t('achievements.total')} - - - - {achievements.stats.uniqueCities} - {t('achievements.cities')} - - - - {achievements.nextAchievement ? ( - - {t('achievements.nextBadge')} - {t(achievements.nextAchievement.title)} - {achievements.nextAchievement.progressText} - - - - - ) : ( - - {t('achievements.allUnlocked')} - {t('achievements.allUnlockedSub')} - - )} - - - - {t('achievements.unlockedSection')} - {achievements.unlocked.length === 0 ? ( - - - {t('achievements.noneYet')} - {t('achievements.noneYetSub')} - - ) : ( - - {achievements.unlocked.map((item) => ( - - - - - {t(item.title)} - {t(item.description)} - - - {t('achievements.unlocked')} - - - ))} - - )} - - - - {t('achievements.lockedSection')} - - {achievements.locked.map((item) => ( - - - - - - - - {t(item.title)} - {t(item.hint)} - - - - - - - - {item.progressText} - - ))} - - - - ); -} - -function createStyles(colors: AppColors) { - return StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background }, - content: { paddingHorizontal: SPACING.xl, paddingBottom: 40 }, - headerRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: SPACING.md, - }, - backBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 }, - backTxt: { fontFamily: FONTS.medium, fontSize: 13, color: colors.primary }, - headerChip: { - paddingHorizontal: SPACING.md, - paddingVertical: 7, - borderRadius: RADIUS.full, - backgroundColor: colors.primaryPale, - borderWidth: 1, - borderColor: colors.borderLight, - }, - headerChipTxt: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.primary }, - heroCard: { - backgroundColor: colors.surface, - borderRadius: RADIUS.xxl, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.xl, - overflow: 'hidden', - marginBottom: SPACING.xl, - }, - heroGlowPrimary: { - position: 'absolute', - width: 180, - height: 180, - borderRadius: 90, - top: -80, - left: -35, - backgroundColor: colors.primaryPale, - }, - heroGlowGold: { - position: 'absolute', - width: 150, - height: 150, - borderRadius: 75, - bottom: -60, - right: -20, - backgroundColor: colors.goldPale, - }, - eyebrow: { - fontFamily: FONTS.medium, - fontSize: 11, - letterSpacing: 1.2, - color: colors.primary, - marginBottom: SPACING.sm, - }, - title: { fontFamily: FONTS.display, fontSize: 30, color: colors.text, marginBottom: SPACING.sm }, - subtitle: { fontFamily: FONTS.regular, fontSize: 14, lineHeight: 22, color: colors.textSecondary }, - heroStats: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: colors.cardMuted, - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.borderLight, - paddingVertical: SPACING.md, - paddingHorizontal: SPACING.sm, - marginTop: SPACING.xl, - }, - heroStat: { flex: 1, alignItems: 'center' }, - heroStatDivider: { width: 1, height: 34, backgroundColor: colors.borderLight }, - heroStatValue: { fontFamily: FONTS.semibold, fontSize: 20, color: colors.text }, - heroStatLabel: { fontFamily: FONTS.regular, fontSize: 11, color: colors.textMuted, marginTop: 3 }, - nextCard: { - marginTop: SPACING.lg, - borderRadius: RADIUS.lg, - backgroundColor: colors.background, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.md, - }, - nextLabel: { fontFamily: FONTS.medium, fontSize: 11, color: colors.textMuted, marginBottom: 4 }, - nextTitle: { fontFamily: FONTS.semibold, fontSize: 16, color: colors.text, marginBottom: 2 }, - nextMeta: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textSecondary, marginBottom: SPACING.sm }, - progressTrack: { - height: 8, - borderRadius: RADIUS.full, - backgroundColor: colors.borderLight, - overflow: 'hidden', - }, - progressFill: { height: '100%', borderRadius: RADIUS.full }, - section: { marginBottom: SPACING.xl }, - sectionTitle: { fontFamily: FONTS.semibold, fontSize: 16, color: colors.text, marginBottom: SPACING.md }, - emptyCard: { - backgroundColor: colors.surface, - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.xl, - alignItems: 'center', - }, - emptyTitle: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.text, marginTop: SPACING.md, marginBottom: 6 }, - emptySubtitle: { fontFamily: FONTS.regular, fontSize: 13, color: colors.textMuted, textAlign: 'center', lineHeight: 20 }, - grid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.md }, - badgeCard: { - width: '47.5%', - backgroundColor: colors.surface, - borderRadius: RADIUS.xl, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.md, - }, - badgeIconWrap: { - width: 46, - height: 46, - borderRadius: 23, - alignItems: 'center', - justifyContent: 'center', - marginBottom: SPACING.md, - }, - badgeTitle: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.text, marginBottom: 4 }, - badgeDescription: { fontFamily: FONTS.regular, fontSize: 12, lineHeight: 18, color: colors.textSecondary, minHeight: 54 }, - statusChip: { - marginTop: SPACING.md, - alignSelf: 'flex-start', - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingHorizontal: SPACING.sm, - paddingVertical: 6, - borderRadius: RADIUS.full, - }, - statusChipTxt: { fontFamily: FONTS.medium, fontSize: 11 }, - lockedList: { gap: SPACING.sm }, - lockedCard: { - backgroundColor: colors.surface, - borderRadius: RADIUS.lg, - borderWidth: 1, - borderColor: colors.borderLight, - padding: SPACING.md, - }, - lockedHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: SPACING.md }, - lockedTitleWrap: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, flex: 1 }, - lockedIconWrap: { - width: 38, - height: 38, - borderRadius: 19, - backgroundColor: colors.cardMuted, - alignItems: 'center', - justifyContent: 'center', - }, - lockedCopy: { flex: 1 }, - lockedTitle: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.text }, - lockedHint: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textMuted, marginTop: 2 }, - lockedProgressTrack: { - height: 8, - borderRadius: RADIUS.full, - backgroundColor: colors.borderLight, - overflow: 'hidden', - marginBottom: SPACING.sm, - }, - lockedProgressFill: { height: '100%', borderRadius: RADIUS.full }, - lockedProgressTxt: { fontFamily: FONTS.medium, fontSize: 12, color: colors.textSecondary }, - }); -} diff --git a/mobile/app/add-activity.tsx b/mobile/app/add-activity.tsx deleted file mode 100644 index 3e663f2..0000000 --- a/mobile/app/add-activity.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { AddActivityScreen } from '../src/components/planner/TripManagementScreens'; - -export default AddActivityScreen; diff --git a/mobile/app/add-plan.tsx b/mobile/app/add-plan.tsx deleted file mode 100644 index 333bac8..0000000 --- a/mobile/app/add-plan.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { AddPlanOptionsScreen } from '../src/components/planner/TripManagementScreens'; - -export default AddPlanOptionsScreen; diff --git a/mobile/app/ai-trip-setup.tsx b/mobile/app/ai-trip-setup.tsx deleted file mode 100644 index 605a4b1..0000000 --- a/mobile/app/ai-trip-setup.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { AiTripSetupScreen } from '../src/components/planner/TripManagementScreens'; - -export default AiTripSetupScreen; diff --git a/mobile/app/bookings.tsx b/mobile/app/bookings.tsx new file mode 100644 index 0000000..9dd4ce3 --- /dev/null +++ b/mobile/app/bookings.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Image, RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router, useFocusEffect } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { FONTS } from '../src/constants/fonts'; +import { RADIUS, SPACING } from '../src/constants/spacing'; +import { type AppColors, useAppTheme } from '../src/theme/app-theme'; +import { bookingsAPI, resolveMediaUrl, type TourBookingItemPayload } from '../src/utils/api'; +import { extractApiData } from '../src/utils/auth'; +import { getItem, KEYS } from '../src/utils/storage'; + +function remaining(deadline?: string | null) { + if (!deadline) return 'Deadline belgilanmagan'; + const distance = new Date(deadline).getTime() - Date.now(); + if (distance <= 0) return 'Javob muddati tugagan'; + const hours = Math.floor(distance / 3_600_000); + const minutes = Math.floor((distance % 3_600_000) / 60_000); + const seconds = Math.floor((distance % 60_000) / 1000); + return `${hours ? `${hours} soat ` : ''}${minutes} daqiqa ${seconds} soniya`; +} + +export default function BookingsScreen() { + const { colors } = useAppTheme(); + const insets = useSafeAreaInsets(); + const styles = createStyles(colors); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [guest, setGuest] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [, setTick] = useState(0); + + const load = useCallback(async (refresh = false) => { + const token = await getItem(KEYS.TOKEN); + if (!token) { + setGuest(true); + setLoading(false); + return; + } + setGuest(false); + if (refresh) setRefreshing(true); + try { + const data = extractApiData<{ items: TourBookingItemPayload[] }>(await bookingsAPI.getMine()); + setItems(data.items || []); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useFocusEffect(useCallback(() => { load(); }, [load])); + useEffect(() => { + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, []); + + return ( + load(true)} tintColor={colors.primary} />} + > + + router.back()}> + + + + WEB VA MOBIL BIR XIL + Mening bookinglarim + + + + {loading ? : null} + {!loading && guest ? ( + + + So‘rovlaringizni kuzatish uchun kiring + + Booking yuborish uchun akkaunt shart emas — tur sahifasida ism va telefon kifoya. Lekin yuborgan + so‘rovlaringiz holatini (qabul qilindi / javob kutilmoqda) shu yerda ko‘rish uchun kirishingiz kerak. + + router.push('/login' as never)}> + Kirish yoki ro‘yxatdan o‘tish + + router.push('/home-tours' as never)}> + Tourlarni ko‘rish + + + ) : null} + {!loading && !guest && items.length === 0 ? ( + + + Booking hali yo‘q + Web yoki mobil ilovada shu akkaunt bilan yaratilgan bookinglar shu yerda chiqadi. + router.push('/home-tours' as never)}> + Tourlarni ko‘rish + + + ) : null} + + {items.map((booking) => { + const imageUrl = resolveMediaUrl(booking.tour?.imageUrl); + return ( + + {imageUrl ? : null} + + + {booking.status} + {booking.totalEstimate ? `${booking.totalEstimate} ${booking.currency}` : 'Narx kelishiladi'} + + {booking.tour?.title || 'Tour'} + {booking.tour?.city} · {booking.tour?.duration} · {booking.travelers} kishi + Agentlik: {booking.agency?.name || 'Belgilanmagan'} + Kontakt: {booking.agency?.phone || booking.agency?.website || 'Kutilmoqda'} + Email: {booking.customerEmail} + {booking.message ? Izoh: {booking.message} : null} + {booking.status === 'pending' ? ( + + + {remaining(booking.responseDeadlineAt)} + + ) : null} + + + ); + })} + + + ); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.background }, + content: { padding: SPACING.lg, gap: SPACING.md }, + header: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, marginBottom: SPACING.md }, + back: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center' }, + eyebrow: { fontFamily: FONTS.semibold, color: colors.primary, fontSize: 10, letterSpacing: 1 }, + title: { fontFamily: FONTS.display, color: colors.text, fontSize: 25 }, + card: { overflow: 'hidden', borderRadius: RADIUS.xl, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.borderLight }, + image: { width: '100%', height: 190 }, + body: { padding: SPACING.lg, gap: 7 }, + statusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: SPACING.sm }, + status: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.primary, textTransform: 'uppercase' }, + price: { fontFamily: FONTS.semibold, fontSize: 13, color: colors.text }, + cardTitle: { fontFamily: FONTS.display, fontSize: 20, color: colors.text }, + muted: { fontFamily: FONTS.regular, fontSize: 12, lineHeight: 18, color: colors.textMuted }, + detail: { fontFamily: FONTS.regular, fontSize: 12, color: colors.textSecondary }, + countdown: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, alignSelf: 'flex-start', marginTop: SPACING.sm, paddingHorizontal: 12, paddingVertical: 8, borderRadius: RADIUS.full, backgroundColor: colors.warningPale }, + countdownText: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.warning }, + empty: { alignItems: 'center', gap: SPACING.md, padding: SPACING.xl, borderRadius: RADIUS.xl, backgroundColor: colors.surface }, + emptyTitle: { fontFamily: FONTS.display, fontSize: 21, color: colors.text }, + primary: { paddingHorizontal: SPACING.xl, paddingVertical: SPACING.md, borderRadius: RADIUS.full, backgroundColor: colors.primary }, + primaryText: { fontFamily: FONTS.semibold, color: colors.textInverse }, + ghostBtn: { + paddingHorizontal: SPACING.xl, + paddingVertical: SPACING.md, + borderRadius: RADIUS.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + ghostBtnText: { fontFamily: FONTS.semibold, color: colors.text }, + }); +} diff --git a/mobile/app/destination/[slug].tsx b/mobile/app/destination/[slug].tsx deleted file mode 100644 index 9f47a2c..0000000 --- a/mobile/app/destination/[slug].tsx +++ /dev/null @@ -1,291 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, ScrollView, Image, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'; -import { router, useLocalSearchParams } from 'expo-router'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTranslation } from 'react-i18next'; - -import Badge from '../../src/components/Badge'; -import { FONTS } from '../../src/constants/fonts'; -import { RADIUS, SPACING } from '../../src/constants/spacing'; -import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; -import { formatSum } from '../../src/utils/formatter'; -import { destinationsAPI } from '../../src/utils/api'; -import { extractApiData } from '../../src/utils/auth'; -import { KEYS, getJSON, saveJSON } from '../../src/utils/storage'; - -export default function DestinationDetail() { - const { slug } = useLocalSearchParams<{ slug: string }>(); - const insets = useSafeAreaInsets(); - const { colors } = useAppTheme(); - const { t } = useTranslation(); - const styles = createStyles(colors); - - const [destRaw, setDestRaw] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!slug) return; - - let active = true; - - (async () => { - const cached = await getJSON(KEYS.DESTINATIONS_CACHE_V1); - const cachedMatch = Array.isArray(cached) - ? cached.find((item) => String(item.slug || '').toLowerCase() === String(slug).toLowerCase()) - : null; - - if (active && cachedMatch) { - setDestRaw(cachedMatch); - setLoading(false); - } - - try { - const res = await destinationsAPI.getById(slug as string); - const destination = extractApiData(res); - if (!destination) return; - - if (active) { - setDestRaw(destination); - setLoading(false); - } - - const nextCache = Array.isArray(cached) ? [...cached] : []; - const idx = nextCache.findIndex((item) => String(item.slug || '').toLowerCase() === String(destination.slug || '').toLowerCase()); - if (idx >= 0) nextCache[idx] = destination; - else nextCache.push(destination); - await saveJSON(KEYS.DESTINATIONS_CACHE_V1, nextCache); - } catch { - if (active && !cachedMatch) { - setLoading(false); - } - } - })(); - - return () => { - active = false; - }; - }, [slug]); - - if (loading) { - return ( - - - - ); - } - - if (!destRaw) { - return ( - - {t('destination.notFound')} - router.back()} style={styles.backFallback}> - {t('destination.back')} - - - ); - } - - // Normalize field names between static (image) and API (imageUrl) - const dest = { - ...destRaw, - image: destRaw.imageUrl || destRaw.image || '', - landmarks: Array.isArray(destRaw.landmarks) ? destRaw.landmarks : [], - hotels: Array.isArray(destRaw.hotels) ? destRaw.hotels : [], - categories: Array.isArray(destRaw.categories) ? destRaw.categories : [], - tags: Array.isArray(destRaw.tags) ? destRaw.tags : [], - bestSeasons: Array.isArray(destRaw.bestSeasons) ? destRaw.bestSeasons : [], - trainPrice: Number(destRaw.trainPrice || 0), - busPrice: Number(destRaw.busPrice || 0), - flightPrice: Number(destRaw.flightPrice || 0), - budgetDaily: Number(destRaw.budgetDaily || 0), - midDaily: Number(destRaw.midDaily || 0), - luxuryDaily: Number(destRaw.luxuryDaily || 0), - }; - - const transports = [ - dest.trainPrice > 0 && { name: t('destination.train'), price: dest.trainPrice }, - dest.busPrice > 0 && { name: t('destination.bus'), price: dest.busPrice }, - dest.flightPrice > 0 && { name: t('destination.plane'), price: dest.flightPrice }, - ].filter(Boolean) as { name: string; price: number }[]; - - const pricingRows = [ - { label: t('destination.budget'), value: dest.budgetDaily, color: colors.success }, - { label: t('destination.mid'), value: dest.midDaily, color: colors.primary }, - { label: t('destination.luxury'), value: dest.luxuryDaily, color: colors.gold }, - ]; - const confidence = Number(destRaw.confidenceScore || 0); - const confidenceLabel = - confidence >= 0.75 ? 'Tekshirilgan maʼlumot' : confidence >= 0.55 ? 'Oʼrtacha ishonch' : 'Taxminiy maʼlumot'; - const verifiedDate = destRaw.lastVerifiedAt ? new Date(destRaw.lastVerifiedAt).toLocaleDateString('uz-UZ') : null; - - return ( - - - - - router.back()}> - {t('destination.back')} - - - {dest.rating} - - - - - {dest.name} - {dest.region} - - - {confidenceLabel}{verifiedDate ? ` • ${verifiedDate}` : ''} - - - {dest.description} - - - {dest.categories.map((category: string) => ( - - ))} - {dest.tags.slice(0, 3).map((tag: string) => ( - - ))} - - - {t('destination.dailyPrice')} - - {pricingRows.map((row) => ( - - {row.label} - {formatSum(row.value)} - - ))} - - - {transports.length > 0 && ( - <> - {t('destination.transport')} - - {transports.map((transport) => ( - - {transport.name} - {formatSum(transport.price)} - - ))} - - - )} - - {t('destination.sights')} - - {dest.landmarks.map((landmark: any, index: number) => ( - - - {landmark.name} - {landmark.duration} {t('destination.minutes')} - - {landmark.entryFee > 0 ? formatSum(landmark.entryFee) : t('destination.free')} - - ))} - - - {t('destination.hotels')} - - {dest.hotels.map((hotel: any, index: number) => ( - - - {hotel.name} - {hotel.stars} {t('destination.stars')} - - {formatSum(hotel.pricePerNight)}{t('destination.perNight')} - - ))} - - - {t('destination.bestSeasons')} - - {dest.bestSeasons.map((season: string) => ( - - ))} - - - - - - - - router.push('/(tabs)/planner')} activeOpacity={0.85}> - {t('destination.planFor')} {dest.name} - - - - ); -} - -function createStyles(colors: AppColors) { - return StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background }, - notFound: { flex: 1, backgroundColor: colors.background, alignItems: 'center', justifyContent: 'center' }, - notFoundTxt: { fontFamily: FONTS.medium, fontSize: 16, color: colors.textMuted, marginBottom: SPACING.md }, - backFallback: { paddingHorizontal: SPACING.lg, paddingVertical: SPACING.md, backgroundColor: colors.primaryPale, borderRadius: RADIUS.md }, - backFallbackTxt: { fontFamily: FONTS.medium, fontSize: 14, color: colors.primary }, - heroWrap: { height: 260, position: 'relative' }, - heroImg: { width: '100%', height: '100%' }, - backBtn: { - position: 'absolute', - top: SPACING.md, - left: SPACING.md, - minWidth: 54, - height: 38, - borderRadius: RADIUS.full, - backgroundColor: colors.overlay, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: SPACING.md, - }, - backArrow: { color: '#fff', fontSize: 12, lineHeight: 16, fontFamily: FONTS.semibold }, - ratingPill: { - position: 'absolute', - top: SPACING.md, - right: SPACING.md, - backgroundColor: colors.overlay, - paddingHorizontal: SPACING.md, - paddingVertical: 5, - borderRadius: RADIUS.full, - }, - ratingTxt: { fontFamily: FONTS.medium, fontSize: 13, color: '#fff' }, - body: { padding: SPACING.lg }, - destName: { fontFamily: FONTS.display, fontSize: 28, color: colors.text, marginBottom: 4 }, - destRegion: { fontFamily: FONTS.medium, fontSize: 13, color: colors.textSecondary, marginBottom: SPACING.md }, - qualityPill: { - alignSelf: 'flex-start', - borderRadius: RADIUS.full, - borderWidth: 1, - borderColor: colors.success, - backgroundColor: colors.successPale, - paddingHorizontal: SPACING.md, - paddingVertical: 5, - marginBottom: SPACING.md, - }, - qualityPillTxt: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.success }, - destDesc: { fontFamily: FONTS.regular, fontSize: 14, color: colors.textSecondary, lineHeight: 22, marginBottom: SPACING.md }, - tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm, marginBottom: SPACING.xl }, - secTitle: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.text, marginBottom: SPACING.sm }, - card: { - backgroundColor: colors.surface, - borderRadius: RADIUS.lg, - padding: SPACING.md, - borderWidth: 1, - borderColor: colors.borderLight, - marginBottom: SPACING.xl, - }, - row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: SPACING.sm }, - rowBorder: { borderBottomWidth: 1, borderBottomColor: colors.borderLight }, - rowLabel: { fontFamily: FONTS.medium, fontSize: 14, color: colors.text }, - rowVal: { fontFamily: FONTS.semibold, fontSize: 14 }, - lmName: { fontFamily: FONTS.medium, fontSize: 13, color: colors.text }, - lmDur: { fontFamily: FONTS.regular, fontSize: 11, color: colors.textMuted, marginTop: 2 }, - lmFee: { fontFamily: FONTS.semibold, fontSize: 13, color: colors.gold }, - cta: { paddingHorizontal: SPACING.lg, paddingTop: SPACING.md, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.borderLight }, - ctaBtn: { backgroundColor: colors.primary, borderRadius: RADIUS.md, paddingVertical: SPACING.md, alignItems: 'center' }, - ctaTxt: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.textInverse }, - }); -} diff --git a/mobile/app/feedback.tsx b/mobile/app/feedback.tsx deleted file mode 100644 index 730fbc0..0000000 --- a/mobile/app/feedback.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { Alert, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTranslation } from 'react-i18next'; -import Constants from 'expo-constants'; - -import Button from '../src/components/Button'; -import { FONTS } from '../src/constants/fonts'; -import { RADIUS, SPACING } from '../src/constants/spacing'; -import { type AppColors, useAppTheme } from '../src/theme/app-theme'; -import { ApiError, feedbackAPI } from '../src/utils/api'; - -type FeedbackCategory = 'suggestion' | 'complaint' | 'bug' | 'feature' | 'other'; - -const CATEGORY_OPTIONS: { - key: FeedbackCategory; - icon: keyof typeof Ionicons.glyphMap; -}[] = [ - { key: 'suggestion', icon: 'bulb-outline' }, - { key: 'feature', icon: 'sparkles-outline' }, - { key: 'bug', icon: 'bug-outline' }, - { key: 'complaint', icon: 'alert-circle-outline' }, - { key: 'other', icon: 'chatbubble-ellipses-outline' }, -]; - -export default function FeedbackScreen() { - const insets = useSafeAreaInsets(); - const { colors } = useAppTheme(); - const { t } = useTranslation(); - const styles = createStyles(colors); - - const [category, setCategory] = useState('suggestion'); - const [subject, setSubject] = useState(''); - const [message, setMessage] = useState(''); - const [contactEmail, setContactEmail] = useState(''); - const [submitting, setSubmitting] = useState(false); - - const categoryLabels = useMemo( - () => ({ - suggestion: t('feedback.categorySuggestion', { defaultValue: 'Taklif' }), - feature: t('feedback.categoryFeature', { defaultValue: 'Yangi funksiya' }), - bug: t('feedback.categoryBug', { defaultValue: 'Xatolik' }), - complaint: t('feedback.categoryComplaint', { defaultValue: 'Shikoyat' }), - other: t('feedback.categoryOther', { defaultValue: 'Boshqa' }), - }), - [t] - ); - - const submitFeedback = async () => { - if (message.trim().length < 8) { - Alert.alert( - t('auth.errorTitle'), - t('feedback.messageTooShort', { defaultValue: "Xabaringiz kamida 8 ta belgi bo'lishi kerak." }) - ); - return; - } - - setSubmitting(true); - try { - await feedbackAPI.submit({ - category, - subject: subject.trim() || undefined, - message: message.trim(), - contactEmail: contactEmail.trim() || undefined, - platform: Platform.OS, - appVersion: - (Constants.expoConfig as { version?: string } | undefined)?.version || - Constants.nativeAppVersion || - undefined, - }); - - Alert.alert( - t('feedback.successTitle', { defaultValue: 'Yuborildi' }), - t('feedback.successBody', { defaultValue: 'Rahmat! Sizning fikringiz jamoamizga yuborildi.' }), - [{ text: t('common.ok'), onPress: () => router.back() }] - ); - setSubject(''); - setMessage(''); - setContactEmail(''); - setCategory('suggestion'); - } catch (err) { - const fallbackText = t('feedback.errorBody', { defaultValue: "Fikrni yuborishda xatolik bo'ldi." }); - const errorText = err instanceof ApiError ? err.message : fallbackText; - Alert.alert(t('auth.errorTitle'), errorText || fallbackText); - } finally { - setSubmitting(false); - } - }; - - return ( - - router.back()} style={styles.backBtn} activeOpacity={0.8}> - - {t('common.back')} - - - - - - - - - {t('feedback.title', { defaultValue: 'Fikr va shikoyatlar' })} - - {t('feedback.subtitle', { - defaultValue: "Taklif, muammo yoki yangi funksiya g'oyangizni yuboring. Biz albatta ko'rib chiqamiz.", - })} - - - - - {t('feedback.categoryLabel', { defaultValue: 'Kategoriya' })} - - {CATEGORY_OPTIONS.map((item) => { - const active = item.key === category; - return ( - setCategory(item.key)} - activeOpacity={0.82} - > - - {categoryLabels[item.key]} - - ); - })} - - - - - {t('feedback.subjectLabel', { defaultValue: 'Sarlavha (ixtiyoriy)' })} - - - - {t('feedback.messageLabel', { defaultValue: 'Xabar' })} - - - - - {t('feedback.contactLabel', { defaultValue: 'Aloqa emaili (ixtiyoriy)' })} - - - - - + ))} + + + {shown.length === 0 ? ( + + ) : ( +
+ {shown.map((i) => { + const isComplaint = i.category === "complaint"; + return ( +
+
+ + {isComplaint ? : } {isComplaint ? "Shikoyat" : "Taklif"} + + +
+ {i.subject ? {i.subject} : null} +

{i.message}

+
+ {i.user?.name || "Mehmon"} · {i.contactEmail || i.user?.email || "—"} · {fmt(i.createdAt)} + +
+
+ ); + })} +
+ )} + + + ); +} diff --git a/website/app/admin/hero/page.tsx b/website/app/admin/hero/page.tsx deleted file mode 100644 index b2cbbcd..0000000 --- a/website/app/admin/hero/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from "next/navigation"; - -export const metadata = { - title: "Hero rasmlar | TravelorAI Admin", -}; - -export default function AdminHeroPage() { - redirect("/admin/content"); -} diff --git a/website/app/admin/layout.tsx b/website/app/admin/layout.tsx index b74f1ad..22c1b69 100644 --- a/website/app/admin/layout.tsx +++ b/website/app/admin/layout.tsx @@ -1,5 +1,24 @@ -import "../../styles/admin-v1.scss"; +"use client"; -export default function AdminLayout({ children }: { children: React.ReactNode }) { - return <>{children}; +import type { ReactNode } from "react"; +import { usePathname } from "next/navigation"; +import AdminGate from "@/components/admin/AdminGate"; +import AdminShell from "@/components/admin/AdminShell"; +import "../../styles/admin.scss"; + +export default function AdminLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + // Login sahifasi gate/shell'siz. + if (pathname === "/admin/login") { + return
{children}
; + } + + return ( +
+ + {children} + +
+ ); } diff --git a/website/app/admin/listings/page.tsx b/website/app/admin/listings/page.tsx new file mode 100644 index 0000000..a00ba88 --- /dev/null +++ b/website/app/admin/listings/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/adminApi"; +import { StatusBadge, Spinner, EmptyState, Toast, ConfirmButton } from "@/components/admin/ui"; +import { publicImageSrc } from "@/lib/imageUrls"; + +type Listing = { + id: string; + title?: string; + city?: string; + approvalStatus?: string; + status?: string; + imageUrl?: string | null; + agency?: { name?: string } | null; + bookingsCount?: number; +}; + +/* eslint-disable @next/next/no-img-element */ +export default function ListingsPage() { + const [items, setItems] = useState(null); + const [toast, setToast] = useState(""); + + useEffect(() => { + api<{ items: Listing[] }>("/admin/tours?status=all").then((d) => setItems(d.items || [])).catch((e) => { setItems([]); setToast(e.message); }); + }, []); + + async function remove(id: string) { + if (!items) return; + const prev = items; + setItems(items.filter((t) => t.id !== id)); + try { await api(`/admin/tours/${id}`, { method: "DELETE" }); } + catch (e) { setItems(prev); setToast(e instanceof Error ? e.message : "O‘chirib bo‘lmadi"); } + } + + if (!items) return ; + + return ( + <> +

Turlar (listing)

+

Platformadagi barcha turlar — egasi, holati va boshqaruv.

+ + {items.length === 0 ? ( + + ) : ( +
+ + + + {items.map((t) => ( + + + + + + + + ))} + +
TurShaharAgentlikHolatAmal
+
+ + {t.imageUrl ? : null} + + {t.title || "—"} +
+
{t.city || "—"}{t.agency?.name || "—"} remove(t.id)} />
+
+ )} + + + ); +} diff --git a/website/app/admin/login/page.tsx b/website/app/admin/login/page.tsx index c371990..43acf55 100644 --- a/website/app/admin/login/page.tsx +++ b/website/app/admin/login/page.tsx @@ -1,19 +1,43 @@ -import { redirect } from "next/navigation"; -import AdminLoginForm from "@/components/admin/AdminLoginForm"; -import { getAdminSession } from "@/lib/admin/session"; +"use client"; -export const metadata = { - title: "Admin kirish | TravelorAI", -}; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { LayoutDashboard, Lock, User } from "lucide-react"; +import { adminLogin, ApiError } from "@/lib/adminApi"; -export default async function AdminLoginPage() { - const session = await getAdminSession(); - if (session) redirect("/admin/hero"); +export default function AdminLoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + async function submitLogin(e: React.FormEvent) { + e.preventDefault(); + setErr(""); setBusy(true); + try { + await adminLogin(username.trim(), password); + router.replace("/admin"); + } catch (e2) { + setErr(e2 instanceof ApiError ? e2.message : "Kirib bo‘lmadi."); + } finally { setBusy(false); } + } return ( -
-
- -
+
+
+ + + +

Admin panel

+

Davom etish uchun login va parolingizni kiriting.

+ {err ?
{err}
: null} +
+
setUsername(e.target.value)} placeholder="admin" required />
+
setPassword(e.target.value)} placeholder="••••••••" required />
+ +
+
+
); } diff --git a/website/app/admin/page.tsx b/website/app/admin/page.tsx index 382e377..f2511a8 100644 --- a/website/app/admin/page.tsx +++ b/website/app/admin/page.tsx @@ -1,5 +1,74 @@ -import { redirect } from "next/navigation"; +"use client"; -export default function AdminIndexPage() { - redirect("/admin/content"); +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Building2, ClipboardList, PackageSearch, TrendingUp, Users } from "lucide-react"; +import { api } from "@/lib/adminApi"; +import { StatCard, Spinner, Toast } from "@/components/admin/ui"; + +type Counts = { users: number; partners: number; pending: number; tours: number; bookings: number }; + +export default function AdminDashboard() { + const [c, setC] = useState(null); + const [err, setErr] = useState(""); + + useEffect(() => { + (async () => { + try { + const [apps, tours, bookings, users] = await Promise.all([ + api<{ items: { status: string }[]; total: number }>("/admin/agency-applications"), + api<{ total: number }>("/admin/tours?status=all"), + api<{ total: number }>("/admin/bookings"), + api<{ total: number }>("/admin/users"), + ]); + setC({ + users: users.total || 0, + partners: apps.total || 0, + pending: (apps.items || []).filter((a) => a.status === "pending").length, + tours: tours.total || 0, + bookings: bookings.total || 0, + }); + } catch (e) { + setErr(e instanceof Error ? e.message : "Yuklab bo‘lmadi"); + } + })(); + }, []); + + if (err) return ; + if (!c) return ; + + const links = [ + { href: "/admin/partners", label: "Hamkorlar", icon: Building2, hint: `${c.pending} ariza kutilmoqda` }, + { href: "/admin/listings", label: "Turlar", icon: PackageSearch, hint: `${c.tours} ta tur` }, + { href: "/admin/bookings", label: "Bronlar", icon: ClipboardList, hint: `${c.bookings} ta bron` }, + { href: "/admin/reports", label: "Hisobotlar", icon: TrendingUp, hint: "Daromad va statistika" }, + ]; + + return ( + <> +

Boshqaruv paneli

+

Platforma holati bir qarashda — foydalanuvchilar, hamkorlar, turlar va bronlar.

+ +
+ } num={c.users} label="Foydalanuvchilar" /> + } num={c.partners} label="Hamkorlar" /> + } num={c.tours} label="Turlar" /> + } num={c.bookings} label="Bronlar" /> + } num={c.pending} label="Kutilayotgan arizalar" /> +
+ +

Tezkor havolalar

+
+ {links.map((l) => { + const Icon = l.icon; + return ( + + +
{l.label}{l.hint}
+ + ); + })} +
+ + ); } diff --git a/website/app/admin/partners/new/page.tsx b/website/app/admin/partners/new/page.tsx new file mode 100644 index 0000000..c962901 --- /dev/null +++ b/website/app/admin/partners/new/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ArrowLeft, CheckCircle2 } from "lucide-react"; +import { api, ApiError } from "@/lib/adminApi"; + +export default function NewPartnerPage() { + const [business, setBusiness] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const [ok, setOk] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setErr(""); + if (!email.trim() || password.length < 8) { setErr("Email va kamida 8 belgili parol majburiy."); return; } + setBusy(true); + try { + await api("/admin/business", { method: "POST", body: JSON.stringify({ name: business.trim(), email: email.trim().toLowerCase(), password, role: "PARTNER" }) }); + setOk(true); + } catch (e2) { + setErr(e2 instanceof ApiError ? e2.message : "Yaratib bo‘lmadi."); + } finally { setBusy(false); } + } + + return ( + <> + Hamkorlar +

Yangi hamkor

+

To‘g‘ridan-to‘g‘ri tasdiqlangan hamkor hisobi yaratiladi.

+ +
+ {ok ? ( +
+ +

Hamkor yaratildi ✅

+

Hisob tasdiqlangan holatda. Hamkor agentlik portaliga kira oladi.

+ Hamkorlarga qaytish +
+ ) : ( +
+ {err ?
{err}
: null} +
setBusiness(e.target.value)} placeholder="Masalan: Guli Travel" />
+
setEmail(e.target.value)} placeholder="agentlik@email.com" required />
+
setPassword(e.target.value)} placeholder="Kamida 8 belgi" required />
+ +
+ )} +
+ + ); +} diff --git a/website/app/admin/partners/page.tsx b/website/app/admin/partners/page.tsx new file mode 100644 index 0000000..8533d1a --- /dev/null +++ b/website/app/admin/partners/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Plus } from "lucide-react"; +import { api } from "@/lib/adminApi"; +import { StatusBadge, Spinner, EmptyState, Toast } from "@/components/admin/ui"; + +type Partner = { + id: string; + status: string; + companyName?: string; + city?: string; + phone?: string; + submittedAt?: string; + account?: { email?: string } | null; +}; + +function fmt(d?: string) { try { return d ? new Date(d).toLocaleDateString("uz-UZ", { day: "numeric", month: "short", year: "numeric" }) : "—"; } catch { return "—"; } } + +export default function PartnersPage() { + const [items, setItems] = useState(null); + const [toast, setToast] = useState(""); + + useEffect(() => { + api<{ items: Partner[] }>("/admin/agency-applications").then((d) => setItems(d.items || [])).catch((e) => { setItems([]); setToast(e.message); }); + }, []); + + async function review(id: string, action: "approve" | "reject", nextStatus: string) { + if (!items) return; + const prev = items; + setItems(items.map((p) => (p.id === id ? { ...p, status: nextStatus } : p))); + try { + await api(`/admin/agency-applications/${id}/${action}`, { method: "PATCH", body: JSON.stringify({ adminNote: "" }) }); + } catch (e) { + setItems(prev); + setToast(e instanceof Error ? e.message : "Amal bajarilmadi"); + } + } + + if (!items) return ; + + return ( + <> +
+
+

Hamkorlar

+

Agentlik arizalari va hisoblari. Kutilayotganlar birinchi.

+
+ Hamkor qo‘shish +
+ + {items.length === 0 ? ( + + ) : ( +
+ + + + {items.map((p) => ( + + + + + + + + + ))} + +
AgentlikEmailShaharHolatSanaAmallar
{p.companyName || "—"}{p.account?.email || "—"}{p.city || "—"}{fmt(p.submittedAt)} +
+ {p.status !== "approved" ? : null} + {p.status !== "rejected" ? : null} +
+
+
+ )} + + + ); +} diff --git a/website/app/admin/reports/page.tsx b/website/app/admin/reports/page.tsx new file mode 100644 index 0000000..1962bb8 --- /dev/null +++ b/website/app/admin/reports/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CalendarRange, Coins, Percent, Wallet } from "lucide-react"; +import { api } from "@/lib/adminApi"; +import { StatCard, StatusBadge, Spinner, Toast } from "@/components/admin/ui"; + +type Reports = { + totalRevenue: number; commission: number; paidBookings: number; last30Days: number; + byStatus: { status: string; count: number }[]; + topTours: { title: string; city: string; bookings: number; revenue: number }[]; + currency: string; +}; + +export default function ReportsPage() { + const [r, setR] = useState(null); + const [err, setErr] = useState(""); + useEffect(() => { api("/admin/reports").then(setR).catch((e) => setErr(e.message)); }, []); + + if (err) return ; + if (!r) return ; + + const money = (v: number) => `${(v || 0).toLocaleString("uz-UZ")} ${r.currency}`; + const maxStatus = Math.max(1, ...r.byStatus.map((s) => s.count)); + + return ( + <> +

Hisobotlar

+

Daromad, komissiya va bron statistikasi.

+ +
+ } num={money(r.totalRevenue)} label="Umumiy daromad" /> + } num={money(r.commission)} label="Platforma komissiyasi (5%)" /> + } num={r.paidBookings} label="To‘langan bronlar" /> + } num={r.last30Days} label="So‘nggi 30 kun bronlari" /> +
+ +
+
+

Bronlar holati bo‘yicha

+ {r.byStatus.length === 0 ?

Ma’lumot yo‘q.

: r.byStatus.map((s) => ( +
+ + + {s.count} +
+ ))} +
+ +
+

Top turlar

+ {r.topTours.length === 0 ?

Hali to‘langan bron yo‘q.

: r.topTours.map((t, i) => ( +
+ {i + 1} +
+ {t.title} + {t.city} · {t.bookings} bron +
+ {money(t.revenue)} +
+ ))} +
+
+ + + ); +} diff --git a/website/app/admin/reviews/page.tsx b/website/app/admin/reviews/page.tsx new file mode 100644 index 0000000..fe3aede --- /dev/null +++ b/website/app/admin/reviews/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Star } from "lucide-react"; +import { api } from "@/lib/adminApi"; +import { Spinner, EmptyState, Toast, ConfirmButton } from "@/components/admin/ui"; + +type Review = { id: string; rating: number; comment: string; createdAt?: string; author?: string; authorEmail?: string; tourTitle?: string }; + +function fmt(d?: string) { try { return d ? new Date(d).toLocaleDateString("uz-UZ", { day: "numeric", month: "short", year: "numeric" }) : "—"; } catch { return "—"; } } + +export default function ReviewsPage() { + const [items, setItems] = useState(null); + const [toast, setToast] = useState(""); + useEffect(() => { api<{ items: Review[] }>("/admin/reviews").then((d) => setItems(d.items || [])).catch((e) => { setItems([]); setToast(e.message); }); }, []); + + async function remove(id: string) { + if (!items) return; + const prev = items; + setItems(items.filter((r) => r.id !== id)); + try { await api(`/admin/reviews/${id}`, { method: "DELETE" }); } + catch (e) { setItems(prev); setToast(e instanceof Error ? e.message : "O‘chirib bo‘lmadi"); } + } + + if (!items) return ; + return ( + <> +

Sharhlar

+

Foydalanuvchi sharhlari — nomaqbullarini o‘chiring.

+ {items.length === 0 ? ( + + ) : ( +
+ {items.map((r) => ( +
+
+
+ {(r.author || "F").charAt(0).toUpperCase()} +
{r.author || "Foydalanuvchi"}
{r.authorEmail || ""}
+
+ {Array.from({ length: 5 }).map((_, i) => )} +
+

{r.comment}

+
+ {r.tourTitle} · {fmt(r.createdAt)} + remove(r.id)} /> +
+
+ ))} +
+ )} + + + ); +} diff --git a/website/app/admin/users/page.tsx b/website/app/admin/users/page.tsx new file mode 100644 index 0000000..c97c823 --- /dev/null +++ b/website/app/admin/users/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { api } from "@/lib/adminApi"; +import { StatusBadge, Spinner, EmptyState, Toast } from "@/components/admin/ui"; + +type U = { id: string; name?: string; lastName?: string; email?: string; status?: string; role?: string; createdAt?: string; _count?: { trips?: number } }; + +function fmt(d?: string) { try { return d ? new Date(d).toLocaleDateString("uz-UZ", { day: "numeric", month: "short", year: "numeric" }) : "—"; } catch { return "—"; } } + +export default function UsersPage() { + const [items, setItems] = useState(null); + const [q, setQ] = useState(""); + const [err, setErr] = useState(""); + useEffect(() => { api<{ items: U[] }>("/admin/users").then((d) => setItems(d.items || [])).catch((e) => { setItems([]); setErr(e.message); }); }, []); + + const filtered = useMemo(() => { + if (!items) return []; + const t = q.trim().toLowerCase(); + if (!t) return items; + return items.filter((u) => `${u.name || ""} ${u.lastName || ""} ${u.email || ""}`.toLowerCase().includes(t)); + }, [items, q]); + + if (!items) return ; + return ( + <> +

Foydalanuvchilar

+

Ro‘yxatdan o‘tgan foydalanuvchilar — {items.length} ta.

+ +
+ setQ(e.target.value)} placeholder="Ism yoki email bo‘yicha qidirish" /> +
+ + {filtered.length === 0 ? ( + + ) : ( +
+ + + + {filtered.map((u) => ( + + + + + + + + + ))} + +
IsmEmailRolHolatSafarlarQo‘shilgan
{[u.name, u.lastName].filter(Boolean).join(" ") || "—"}{u.email || "—"}{u._count?.trips ?? 0}{fmt(u.createdAt)}
+
+ )} + + + ); +} diff --git a/website/app/agency/layout.tsx b/website/app/agency/layout.tsx index 3f49035..af50e70 100644 --- a/website/app/agency/layout.tsx +++ b/website/app/agency/layout.tsx @@ -1,9 +1,20 @@ import "../../styles/agency-v1.scss"; +import { AgencySessionProvider } from "@/lib/agency/session"; +import AgencyShell from "@/components/agency/AgencyShell"; + +export const metadata = { + title: "Agency Portal | TravelorAI", + description: "TravelorAI tour agency boshqaruv paneli.", +}; export default function AgencyLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return children; + return ( + + {children} + + ); } diff --git a/website/app/agency/leads/page.tsx b/website/app/agency/leads/page.tsx new file mode 100644 index 0000000..a690567 --- /dev/null +++ b/website/app/agency/leads/page.tsx @@ -0,0 +1,9 @@ +import LeadsBoard from "@/components/agency/LeadsBoard"; + +export const metadata = { + title: "Leadlar | TravelorAI Agency", +}; + +export default function AgencyLeadsPage() { + return ; +} diff --git a/website/app/agency/page.tsx b/website/app/agency/page.tsx index b0a97da..18fb3a6 100644 --- a/website/app/agency/page.tsx +++ b/website/app/agency/page.tsx @@ -1,10 +1,5 @@ -import AgencyPortal from "@/components/agency/AgencyPortal"; +import AgencyDashboard from "@/components/agency/AgencyDashboard"; -export const metadata = { - title: "Agency Portal | TravelorAI", - description: "TravelorAI tour agency onboarding and tour dashboard.", -}; - -export default function AgencyPage() { - return ; +export default function AgencyHomePage() { + return ; } diff --git a/website/app/agency/profile/page.tsx b/website/app/agency/profile/page.tsx new file mode 100644 index 0000000..622264b --- /dev/null +++ b/website/app/agency/profile/page.tsx @@ -0,0 +1,9 @@ +import ProfileEditor from "@/components/agency/ProfileEditor"; + +export const metadata = { + title: "Profil | TravelorAI Agency", +}; + +export default function AgencyProfilePage() { + return ; +} diff --git a/website/app/agency/tours/[id]/page.tsx b/website/app/agency/tours/[id]/page.tsx new file mode 100644 index 0000000..426367b --- /dev/null +++ b/website/app/agency/tours/[id]/page.tsx @@ -0,0 +1,10 @@ +import TourEditor from "@/components/agency/TourEditor"; + +export const metadata = { + title: "Tourni tahrirlash | TravelorAI Agency", +}; + +export default async function AgencyEditTourPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/website/app/agency/tours/new/page.tsx b/website/app/agency/tours/new/page.tsx new file mode 100644 index 0000000..85bb308 --- /dev/null +++ b/website/app/agency/tours/new/page.tsx @@ -0,0 +1,9 @@ +import TourEditor from "@/components/agency/TourEditor"; + +export const metadata = { + title: "Yangi tour | TravelorAI Agency", +}; + +export default function AgencyNewTourPage() { + return ; +} diff --git a/website/app/agency/tours/page.tsx b/website/app/agency/tours/page.tsx new file mode 100644 index 0000000..ad6f89c --- /dev/null +++ b/website/app/agency/tours/page.tsx @@ -0,0 +1,9 @@ +import ToursList from "@/components/agency/ToursList"; + +export const metadata = { + title: "Tourlarim | TravelorAI Agency", +}; + +export default function AgencyToursPage() { + return ; +} diff --git a/website/app/api/admin-auth/login/route.ts b/website/app/api/admin-auth/login/route.ts index e3b0a13..727aed8 100644 --- a/website/app/api/admin-auth/login/route.ts +++ b/website/app/api/admin-auth/login/route.ts @@ -1,15 +1,28 @@ +import crypto from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { setAdminSession } from "@/lib/admin/session"; +function safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); const username = String(body.username || "").trim(); const password = String(body.password || ""); - const expectedUsername = process.env.ADMIN_USERNAME || "admin"; - const expectedPassword = process.env.ADMIN_PASSWORD || "admin123"; + const expectedUsername = process.env.ADMIN_USERNAME; + const expectedPassword = process.env.ADMIN_PASSWORD; + if (!expectedUsername || !expectedPassword) { + return NextResponse.json( + { success: false, message: "Admin login sozlanmagan" }, + { status: 503 } + ); + } - if (username !== expectedUsername || password !== expectedPassword) { + if (!safeEqual(username, expectedUsername) || !safeEqual(password, expectedPassword)) { return NextResponse.json( { success: false, message: "Login yoki parol xato" }, { status: 401 } diff --git a/website/app/api/admin-proxy/[...path]/route.ts b/website/app/api/admin-proxy/[...path]/route.ts index 75c9441..b8fe5ac 100644 --- a/website/app/api/admin-proxy/[...path]/route.ts +++ b/website/app/api/admin-proxy/[...path]/route.ts @@ -9,6 +9,7 @@ const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); const DEFAULT_LOCAL_API_BASE = "http://localhost:4000/api/v1"; const DEFAULT_ADMIN_SECRET = "change_me"; const DEFAULT_PROXY_TIMEOUT_MS = 15000; +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); function resolveRequestHost(request: NextRequest) { const forwardedHost = request.headers.get("x-forwarded-host"); @@ -72,12 +73,38 @@ function getAdminSecret(request: NextRequest) { return ""; } +function requestOrigin(request: NextRequest) { + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0].trim(); + const protocol = forwardedProto || request.nextUrl.protocol.replace(":", "") || "https"; + const host = resolveRequestHost(request); + return host ? `${protocol}://${host}` : request.nextUrl.origin; +} + +function hasValidOrigin(request: NextRequest) { + if (SAFE_METHODS.has(request.method)) return true; + const origin = request.headers.get("origin"); + if (!origin) return false; + + const allowed = new Set([ + requestOrigin(request), + request.nextUrl.origin, + process.env.NEXT_PUBLIC_SITE_URL, + process.env.ADMIN_SITE_URL, + ].filter((value): value is string => Boolean(value))); + + return allowed.has(origin); +} + async function proxyAdminRequest(request: NextRequest, context: RouteContext) { const session = await getAdminSession(); if (!session) { return NextResponse.json({ success: false, message: "Admin login kerak" }, { status: 401 }); } + if (!hasValidOrigin(request)) { + return NextResponse.json({ success: false, message: "So'rov manbasi ruxsat etilmagan" }, { status: 403 }); + } + const adminKey = getAdminSecret(request); if (!adminKey) { return NextResponse.json( diff --git a/website/app/api/agency-proxy/[...path]/route.ts b/website/app/api/agency-proxy/[...path]/route.ts index 091f2d6..bdeb15c 100644 --- a/website/app/api/agency-proxy/[...path]/route.ts +++ b/website/app/api/agency-proxy/[...path]/route.ts @@ -6,6 +6,14 @@ type RouteContext = { const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); const DEFAULT_PROXY_TIMEOUT_MS = 15000; +const AGENCY_TOKEN_COOKIE = "travelorai_agency_token"; +const AGENCY_SESSION_SECONDS = 60 * 60 * 24 * 7; +const TOKEN_RESPONSE_PATHS = new Set([ + "agency/auth/login", + "agency/auth/google", + "agency/auth/verify-email", + "agency/auth/email-change/confirm", +]); function normalizeApiBase(value: string) { return value.replace(/\/$/, ""); @@ -31,15 +39,49 @@ function getAgencyApiBase(request: NextRequest) { return "https://travelorai.com/api/v1"; } +function secureCookie() { + return process.env.NODE_ENV === "production" + || /^https:\/\//i.test(process.env.NEXT_PUBLIC_SITE_URL || ""); +} + +function clearAgencyCookie(response: NextResponse) { + response.cookies.set(AGENCY_TOKEN_COOKIE, "", { + httpOnly: true, + secure: secureCookie(), + sameSite: "lax", + path: "/", + maxAge: 0, + }); +} + +function validRequestOrigin(request: NextRequest) { + if (!METHODS_WITH_BODY.has(request.method)) return true; + const origin = request.headers.get("origin"); + if (!origin) return false; + return origin === request.nextUrl.origin + || origin === process.env.NEXT_PUBLIC_SITE_URL + || origin === process.env.AGENCY_SITE_URL; +} + async function proxyAgencyRequest(request: NextRequest, context: RouteContext) { const params = await context.params; const rawPathParts = params.path || []; const path = rawPathParts.map(encodeURIComponent).join("/"); + if (path === "agency/auth/logout" && request.method === "POST") { + const response = NextResponse.json({ success: true, data: { loggedOut: true } }); + clearAgencyCookie(response); + return response; + } + if (!validRequestOrigin(request)) { + return NextResponse.json({ success: false, message: "So'rov manbasi ruxsat etilmagan" }, { status: 403 }); + } + const target = `${getAgencyApiBase(request)}/${path}${request.nextUrl.search}`; const isHealthCheck = rawPathParts.length === 1 && String(rawPathParts[0]).toLowerCase() === "health"; const headers = new Headers(); - const authorization = request.headers.get("authorization"); + const cookieToken = request.cookies.get(AGENCY_TOKEN_COOKIE)?.value; + const authorization = request.headers.get("authorization") || (cookieToken ? `Bearer ${cookieToken}` : null); if (authorization) headers.set("authorization", authorization); const contentType = request.headers.get("content-type"); @@ -90,12 +132,32 @@ async function proxyAgencyRequest(request: NextRequest, context: RouteContext) { } const text = await backendResponse.text(); - return new NextResponse(text, { + const responseContentType = backendResponse.headers.get("content-type") || "application/json"; + if (TOKEN_RESPONSE_PATHS.has(path) && backendResponse.ok && responseContentType.includes("application/json")) { + const payload = JSON.parse(text); + const token = payload?.data?.token; + if (token) { + delete payload.data.token; + const response = NextResponse.json(payload, { status: backendResponse.status }); + response.cookies.set(AGENCY_TOKEN_COOKIE, token, { + httpOnly: true, + secure: secureCookie(), + sameSite: "lax", + path: "/", + maxAge: AGENCY_SESSION_SECONDS, + }); + return response; + } + } + + const response = new NextResponse(text, { status: backendResponse.status, headers: { - "content-type": backendResponse.headers.get("content-type") || "application/json", + "content-type": responseContentType, }, }); + if (backendResponse.status === 401 && cookieToken) clearAgencyCookie(response); + return response; } export const GET = proxyAgencyRequest; diff --git a/website/app/api/backend/[...path]/route.ts b/website/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..dc2bdd4 --- /dev/null +++ b/website/app/api/backend/[...path]/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; + +type RouteContext = { + params: Promise<{ path?: string[] }> | { path?: string[] }; +}; + +const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const DEFAULT_PROXY_TIMEOUT_MS = 15000; +const USER_TOKEN_COOKIE = "travelorai_user_token"; +const USER_SESSION_SECONDS = 60 * 60 * 24 * 7; +const TOKEN_RESPONSE_PATHS = new Set([ + "auth/login", + "auth/verify-email", + "auth/google", + "auth/email-change/verify", +]); + +function apiBase(request: NextRequest) { + const configured = (process.env.NEXT_PUBLIC_API_URL || "").replace(/\/$/, ""); + if (/^https?:\/\//i.test(configured)) return configured; + if (request.nextUrl.hostname === "localhost" || request.nextUrl.hostname === "127.0.0.1") { + return "http://localhost:4000/api/v1"; + } + return "https://travelorai.com/api/v1"; +} + +function secureCookie() { + return process.env.NODE_ENV === "production" + || /^https:\/\//i.test(process.env.NEXT_PUBLIC_SITE_URL || ""); +} + +function clearUserCookie(response: NextResponse) { + response.cookies.set(USER_TOKEN_COOKIE, "", { + httpOnly: true, + secure: secureCookie(), + sameSite: "lax", + path: "/", + maxAge: 0, + }); +} + +function validRequestOrigin(request: NextRequest) { + if (!METHODS_WITH_BODY.has(request.method)) return true; + const origin = request.headers.get("origin"); + if (!origin) return false; + return origin === request.nextUrl.origin + || origin === process.env.NEXT_PUBLIC_SITE_URL; +} + +async function proxyRequest(request: NextRequest, context: RouteContext) { + const params = await context.params; + const path = (params.path || []).map(encodeURIComponent).join("/"); + if (path === "auth/logout" && request.method === "POST") { + const response = NextResponse.json({ success: true, data: { loggedOut: true } }); + clearUserCookie(response); + return response; + } + if (!validRequestOrigin(request)) { + return NextResponse.json({ success: false, message: "So'rov manbasi ruxsat etilmagan" }, { status: 403 }); + } + + const target = `${apiBase(request)}/${path}${request.nextUrl.search}`; + const headers = new Headers(); + const cookieToken = request.cookies.get(USER_TOKEN_COOKIE)?.value; + const authorization = request.headers.get("authorization") || (cookieToken ? `Bearer ${cookieToken}` : null); + const contentType = request.headers.get("content-type"); + if (authorization) headers.set("authorization", authorization); + if (contentType) headers.set("content-type", contentType); + + const init: RequestInit = { method: request.method, headers, cache: "no-store" }; + if (METHODS_WITH_BODY.has(request.method)) { + const body = await request.arrayBuffer(); + if (body.byteLength) init.body = body; + } + + const timeoutCandidate = Number.parseInt(process.env.BACKEND_PROXY_TIMEOUT_MS || "", 10); + const timeoutMs = Number.isFinite(timeoutCandidate) && timeoutCandidate > 0 + ? timeoutCandidate + : DEFAULT_PROXY_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(target, { ...init, signal: controller.signal }); + const text = await response.text(); + const responseContentType = response.headers.get("content-type") || "application/json"; + + if (TOKEN_RESPONSE_PATHS.has(path) && response.ok && responseContentType.includes("application/json")) { + const payload = JSON.parse(text); + const token = payload?.data?.token; + if (token) { + delete payload.data.token; + const nextResponse = NextResponse.json(payload, { status: response.status }); + nextResponse.cookies.set(USER_TOKEN_COOKIE, token, { + httpOnly: true, + secure: secureCookie(), + sameSite: "lax", + path: "/", + maxAge: USER_SESSION_SECONDS, + }); + return nextResponse; + } + } + + const nextResponse = new NextResponse(text, { + status: response.status, + headers: { "content-type": responseContentType }, + }); + if (response.status === 401 && cookieToken) clearUserCookie(nextResponse); + return nextResponse; + } catch (err) { + const isTimeout = err instanceof Error && err.name === "AbortError"; + return NextResponse.json( + { + success: false, + message: isTimeout + ? `Backend ${timeoutMs}ms ichida javob bermadi` + : "Backend bilan aloqa qilib bo'lmadi", + }, + { status: isTimeout ? 504 : 502 } + ); + } finally { + clearTimeout(timeoutHandle); + } +} + +export const GET = proxyRequest; +export const POST = proxyRequest; +export const PUT = proxyRequest; +export const PATCH = proxyRequest; +export const DELETE = proxyRequest; diff --git a/website/app/auth/page.tsx b/website/app/auth/page.tsx new file mode 100644 index 0000000..3697537 --- /dev/null +++ b/website/app/auth/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AuthPage() { + redirect("/login"); +} diff --git a/website/app/contact/page.tsx b/website/app/contact/page.tsx new file mode 100644 index 0000000..da49336 --- /dev/null +++ b/website/app/contact/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { Mail, MapPin, Phone, Send } from "lucide-react"; +import MarketingShell from "@/components/marketing/MarketingShell"; +import PageHero from "@/components/marketing/PageHero"; +import Reveal from "@/components/marketing/Reveal"; +import ContactForm from "@/components/marketing/ContactForm"; + +export const metadata: Metadata = { + title: "Aloqa", + description: "TravelorAI bilan bog'laning — telefon, email, Telegram yoki xabar formasi orqali.", + alternates: { canonical: "/contact" }, +}; + +const HERO = "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=1920&q=70"; + +const CHANNELS = [ + { icon: Phone, title: "Telefon", value: "+998 90 000 00 00", href: "tel:+998900000000" }, + { icon: Mail, title: "Email", value: "support@travelorai.com", href: "mailto:support@travelorai.com" }, + { icon: Send, title: "Telegram", value: "@travelorai", href: "https://t.me/travelorai" }, + { icon: MapPin, title: "Manzil", value: "Toshkent, O'zbekiston", href: "https://maps.google.com/?q=Tashkent" }, +]; + +export default function ContactPage() { + return ( + + +
+
+
+ + Aloqa kanallari +

To'g'ridan-to'g'ri bog'laning

+
+ {CHANNELS.map((c) => { + const Icon = c.icon; + return ( + + + {c.title}{c.value} + + ); + })} +
+
+ + + +
+
+
+
+ ); +} diff --git a/website/app/destinations/page.tsx b/website/app/destinations/page.tsx new file mode 100644 index 0000000..f1c5d26 --- /dev/null +++ b/website/app/destinations/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import MarketingShell from "@/components/marketing/MarketingShell"; +import PageHero from "@/components/marketing/PageHero"; +import Reveal from "@/components/marketing/Reveal"; +import { fetchPlaces, fetchTours } from "@/lib/marketingApi"; +import { REGIONS } from "@/lib/travelData"; +import { publicImageSrc } from "@/lib/imageUrls"; + +export const metadata: Metadata = { + title: "Yo‘nalishlar", + description: "TravelorAI yo‘nalishlari — BAA, Turkiya, Misr, Tailand va boshqalar. Har bir yo‘nalish bo‘yicha tasdiqlangan turlar.", + alternates: { canonical: "/destinations" }, +}; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const HERO = "https://images.unsplash.com/photo-1488646953014-85cb44e25828?auto=format&fit=crop&w=1920&q=70"; + +/* eslint-disable @next/next/no-img-element */ +export default async function DestinationsPage() { + const [places, tours] = await Promise.all([fetchPlaces(24), fetchTours(60)]); + + // Har bir region uchun tur sonini hisoblaymiz + namuna rasm + const regionCards = REGIONS.filter((r) => r.key !== "all").map((r) => { + const count = tours.filter((t) => { + const hay = `${t.title} ${t.city} ${t.destinationCountry || ""} ${t.subtitle || ""}`.toLowerCase(); + return r.match.some((m) => hay.includes(m)); + }).length; + const place = places.find((p) => r.match.some((m) => `${p.name} ${p.city}`.toLowerCase().includes(m))); + return { ...r, count, image: place?.imageUrl || null }; + }); + + return ( + + +
+
+
+ {regionCards.map((r, i) => ( + + + {r.image ? {r.label} : null} + + {r.label} + {r.count > 0 ? `${r.count} ta tur` : "Tez orada"} + + + + ))} +
+
+
+
+ ); +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 43a10eb..f08fa54 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -1,6 +1,15 @@ import type { Metadata, Viewport } from "next"; +import { Plus_Jakarta_Sans } from "next/font/google"; import "./globals.scss"; import "../styles/landing-v2.scss"; +import "../styles/marketing.scss"; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600", "700", "800"], + variable: "--font-jakarta", + display: "swap", +}); const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://travelorai.com"; const SOCIAL_LINKS = [ @@ -17,11 +26,11 @@ export const viewport: Viewport = { export const metadata: Metadata = { metadataBase: new URL(BASE_URL), title: { - default: "TravelorAI - Global AI travel platform", + default: "TravelorAI — AI sayohat platformasi", template: "%s | TravelorAI", }, description: - "Plan trips around the world with AI. Verified places, smart routes, agency rankings and mobile-first travel experience.", + "AI yordamida sayohatlarni rejalashtiring. Tasdiqlangan joylar, aqlli marshrutlar, agentlik reytinglari va mobil-birinchi sayohat tajribasi.", keywords: [ "TravelorAI", "global travel planner", @@ -43,9 +52,9 @@ export const metadata: Metadata = { googleBot: { index: true, follow: true, "max-image-preview": "large" }, }, openGraph: { - title: "TravelorAI - Global AI travel platform", + title: "TravelorAI — AI sayohat platformasi", description: - "Create personal travel plans for destinations around the world. AI planner, verified places, agency rankings and smart discovery.", + "Dunyo bo‘ylab yo‘nalishlar uchun shaxsiy sayohat rejalarini yarating. AI rejalashtiruvchi, tasdiqlangan joylar, agentlik reytinglari va aqlli kashfiyot.", url: BASE_URL, siteName: "TravelorAI", type: "website", @@ -53,25 +62,24 @@ export const metadata: Metadata = { alternateLocale: ["ru_RU", "en_US"], images: [ { - url: "/og-image.png", + url: "/og-image.svg", width: 1200, height: 630, - alt: "TravelorAI - Global AI travel platform", + alt: "TravelorAI — AI sayohat platformasi", }, ], }, twitter: { card: "summary_large_image", - title: "TravelorAI - Global AI travel platform", - description: "Create personal travel plans for destinations around the world.", - images: ["/og-image.png"], + title: "TravelorAI — AI sayohat platformasi", + description: "Dunyo bo‘ylab yo‘nalishlar uchun shaxsiy sayohat rejalarini yarating.", + images: ["/og-image.svg"], }, alternates: { canonical: BASE_URL, }, icons: { icon: "/favicon.ico", - apple: "/apple-touch-icon.png", }, }; @@ -93,7 +101,7 @@ const jsonLd = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +