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/schema.prisma b/backend/prisma/schema.prisma index ff16a6b..4a3a729 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,27 @@ 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) + 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 +74,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 +110,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 +142,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 +194,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 +210,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 +255,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 +298,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 +419,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 +487,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 +522,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 { 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..faf49f8 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); 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..638bc36 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); } } @@ -297,6 +305,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 +351,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 +448,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 +611,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, @@ -473,5 +637,9 @@ module.exports = { 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/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..5b2fd9c 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -11,7 +11,11 @@ const { 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 +30,9 @@ const { profileSchema, preferencesSchema, deleteAccountSchema, + requestEmailChangeSchema, + verifyEmailChangeSchema, + requestAccountDeletionSchema, } = require('../schemas/auth.schema'); router.post('/register', validate(registerSchema), register); @@ -39,6 +46,10 @@ 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..4e0c27e 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 = { @@ -44,7 +52,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 +71,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/.gitignore b/mobile/.gitignore index d650667..011f8a5 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -45,3 +45,8 @@ app-example # generated native folders /ios + +# Firebase (maxfiy) +google-services.json +*-firebase-adminsdk-*.json +fcm-service-account.json diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 0ed4942..94b99e0 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')) ?: '26').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..f30a4ae 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.8", + "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": 28, "adaptiveIcon": { "backgroundColor": "#041A0F", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -30,7 +28,8 @@ "permissions": [ "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION" - ] + ], + "googleServicesFile": "./google-services.json" }, "web": { "output": "static", diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 29324bb..5c573e7 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,14 +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'], + tours: ['briefcase', 'briefcase-outline'], + planner: ['sparkles', 'sparkles-outline'], explore: ['compass', 'compass-outline'], profile: ['person', 'person-outline'], }; @@ -44,6 +53,7 @@ export default function TabLayout() { })} > + @@ -57,7 +67,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 +78,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 +113,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 index 7350018..cb5cf4e 100644 --- a/mobile/app/(tabs)/explore.tsx +++ b/mobile/app/(tabs)/explore.tsx @@ -1028,7 +1028,8 @@ export default function ExploreScreen() { const insets = useSafeAreaInsets(); const safeBottom = Math.max(insets.bottom, 22); const styles = useMemo(() => createStyles(colors), [colors]); - const { tripId } = useLocalSearchParams<{ tripId?: string }>(); + const { tripId, q: routeQuery, category: routeCategory, radius: routeRadius } = + useLocalSearchParams<{ tripId?: string; q?: string; category?: string; radius?: string }>(); const tt = useCallback( (key: string, fallback: string, values?: Record) => @@ -1072,6 +1073,18 @@ export default function ExploreScreen() { const [savingStopId, setSavingStopId] = useState(null); const [listVersion, setListVersion] = useState(0); + useEffect(() => { + if (typeof routeQuery === 'string') setQuery(routeQuery); + if (typeof routeCategory === 'string' && ['all', 'restaurant', 'hotel', 'landmark', 'transport'].includes(routeCategory)) { + setCategory(routeCategory as CategoryFilter); + } + const nextRadius = Number(routeRadius); + if (RADIUS_OPTIONS.includes(nextRadius)) { + setRadiusKm(nextRadius); + setLoadMode('radius'); + } + }, [routeCategory, routeQuery, routeRadius]); + const subchipsAnim = useRef(new Animated.Value(0)).current; const locationWatcherRef = useRef(null); const lastPlacesRequestKeyRef = useRef(null); @@ -2540,9 +2553,7 @@ export default function ExploreScreen() { ) : ( - router.push('/side-menu' as any)} activeOpacity={0.82}> - - + TravelorAI router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 7cbf569..6bece7c 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -11,15 +11,19 @@ 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 AiSpark from '../../src/components/AiSpark'; import { FONTS } from '../../src/constants/fonts'; import { RADIUS, SPACING } from '../../src/constants/spacing'; +import { primaryGlow } from '../../src/constants/effects'; 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 { useWishlist } from '../../src/hooks/useWishlist'; import { HOME_DEFAULT_HERO, PLACE_FILTERS, @@ -67,6 +71,7 @@ export default function HomeScreen() { const [placeFilter, setPlaceFilter] = useState('all'); const [heroIndex, setHeroIndex] = useState(0); const [user, setUser] = useState(null); + const { isWishlisted, toggle: toggleWishlist } = useWishlist(user?.id || null); const loadHomeData = useCallback(async () => { const [savedUser, cached] = await Promise.all([ @@ -87,9 +92,10 @@ export default function HomeScreen() { if (cachedAgencies.length > 0) setHomeAgencies(cachedAgencies); if (cachedHeroSlides.length > 0) setHeroSlides(cachedHeroSlides); - const [homeResult, heroSlidesResult] = await Promise.allSettled([ + const [homeResult, heroSlidesResult, toursResult] = await Promise.allSettled([ homeAPI.getHome({ limit: 48 }), homeAPI.getHeroSlides({ limit: 8 }), + homeAPI.getTours({ agencyOnly: true, limit: 12, page: 1 }), ]); try { @@ -107,7 +113,9 @@ export default function HomeScreen() { ? extractApiData(heroSlidesResult.value) || {} : {}; const nextPlaces = hasFreshHomePayload ? normalizePopularPlaces(payload.places || []) : cachedPlaces; - const nextTours = hasFreshHomePayload ? normalizeTours(payload.tours || []) : cachedTours; + const toursPayload = + toursResult.status === 'fulfilled' ? extractApiData<{ items?: any[] }>(toursResult.value) || {} : {}; + const nextTours = toursResult.status === 'fulfilled' ? normalizeTours(toursPayload.items || []) : cachedTours; const nextAgencies = hasFreshHomePayload ? normalizeAgencies(payload.agencies || []) : cachedAgencies; const directHeroSlides = normalizeHeroSlides(heroPayload.items || []); const nextHeroSlides = directHeroSlides.length > 0 ? directHeroSlides : normalizeHeroSlides(payload.heroSlides || []); @@ -202,8 +210,23 @@ export default function HomeScreen() { openPlace(item)}> - - + { + event.stopPropagation(); + void toggleWishlist({ + id: item.id, + poiId: item.id, + name: item.name, + city: item.city || 'Global', + slug: item.slug, + type: item.type, + icon: '📍', + }); + }} + > + {item.name} @@ -268,9 +291,7 @@ export default function HomeScreen() { refreshControl={} > - router.push('/side-menu' as any)} activeOpacity={0.82}> - - + TravelorAI router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> @@ -311,6 +332,30 @@ export default function HomeScreen() { + router.push('/(tabs)/planner' as any)} + > + + + + + + AI bilan sayohat rejasi + Byudjet va qiziqishlaringizga mos reja — bir necha soniyada + + + + + + + router.push('/home-places' as any)} styles={styles} /> @@ -598,7 +643,49 @@ function createStyles(colors: AppColors) { borderRadius: 21, alignItems: 'center', justifyContent: 'center', - backgroundColor: colors.success, + backgroundColor: colors.primary, + }, + aiCtaWrap: { + marginHorizontal: SPACING.lg, + marginBottom: SPACING.xl, + borderRadius: RADIUS.card, + ...primaryGlow(colors), + }, + aiCta: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: RADIUS.card, + paddingVertical: SPACING.lg, + paddingHorizontal: SPACING.lg, + gap: SPACING.md, + }, + aiCtaSpark: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + aiCtaCopy: { flex: 1 }, + aiCtaTitle: { + fontFamily: FONTS.display, + fontSize: 17, + color: colors.onGradient, + }, + aiCtaSub: { + fontFamily: FONTS.regular, + fontSize: 12.5, + color: colors.onGradient, + opacity: 0.82, + marginTop: 2, + lineHeight: 17, + }, + aiCtaArrow: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: 'rgba(255,255,255,0.28)', + alignItems: 'center', + justifyContent: 'center', }, heroDots: { flexDirection: 'row', @@ -630,7 +717,7 @@ function createStyles(colors: AppColors) { fontSize: 22, color: colors.text, }, - seeAll: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.success }, + seeAll: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.primary }, filterRow: { paddingLeft: SPACING.lg, paddingRight: SPACING.lg, diff --git a/mobile/app/(tabs)/planner.tsx b/mobile/app/(tabs)/planner.tsx index 073a2a5..3ce3475 100644 --- a/mobile/app/(tabs)/planner.tsx +++ b/mobile/app/(tabs)/planner.tsx @@ -3,7 +3,6 @@ import { ActivityIndicator, Animated, Alert, - ImageBackground, ScrollView, StyleSheet, Text, @@ -12,17 +11,19 @@ import { View, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; import { router, useFocusEffect } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import LottieAnim from '../../src/components/LottieAnim'; import { FONTS } from '../../src/constants/fonts'; import { RADIUS, SPACING } from '../../src/constants/spacing'; +import { primaryGlow } from '../../src/constants/effects'; import { type AppColors, useAppTheme } from '../../src/theme/app-theme'; -import { citiesAPI, destinationsAPI, homeAPI, plannerAPI, poiAPI, type PoiPayload } from '../../src/utils/api'; +import { citiesAPI, destinationsAPI, 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, @@ -476,10 +477,6 @@ export default function PlannerScreen() { 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); @@ -499,40 +496,8 @@ export default function PlannerScreen() { 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 openMyPlans = useCallback(() => { + router.push('/my-plans' as any); }, []); const steps = useMemo( @@ -628,12 +593,10 @@ export default function PlannerScreen() { ); 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]); + }, [anim, step]); useEffect(() => { if (!loading) { @@ -731,10 +694,6 @@ export default function PlannerScreen() { 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 || '-' }, @@ -876,52 +835,6 @@ export default function PlannerScreen() { } }; - 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 ( @@ -952,79 +865,25 @@ export default function PlannerScreen() { 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 + + + AI Reja + Sayohatingizni AI yordamida bosqichma-bosqich rejalashtiring. + + + + 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}> - - - - ) : ( - <> - - - + @@ -1042,7 +901,6 @@ export default function PlannerScreen() { ))} - {steps.map((item, index) => ( @@ -1173,15 +1031,17 @@ export default function PlannerScreen() { {step > 0 ? {t('planner.back')} : null} - {step < steps.length - 1 ? t('planner.next') : loading ? t('planner.generating') : tt('planner.generateBtn', 'AI Reja yaratish')} + + + {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]} @@ -1191,31 +1051,6 @@ export default function PlannerScreen() { )} - {actionMenuOpen && ( - - setActionMenuOpen(false)} /> - - - Trip yaratish - - - - - - AI bilan reja - - - - - setActionMenuOpen(false)} activeOpacity={0.86}> - Yopish - - - - - - - )} ); } @@ -1261,280 +1096,40 @@ function createStyles(colors: AppColors) { 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: { + pageIntroRow: { 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: { + plansBtn: { flexDirection: 'row', alignItems: 'center', - gap: 4, + gap: 6, + height: 38, + paddingHorizontal: SPACING.md, borderRadius: RADIUS.full, - backgroundColor: colors.surface, - paddingHorizontal: 9, - paddingVertical: 5, + backgroundColor: colors.primaryPale, }, - tourRatingTxt: { + plansBtnTxt: { 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, + color: colors.primary, }, - quickActionText: { + pageTitle: { fontFamily: FONTS.display, - fontSize: 22, - color: '#fff', - textAlign: 'right', - textShadowColor: 'rgba(0,0,0,0.35)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 6, + fontSize: 24, + color: colors.text, + marginBottom: 4, }, - 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, + pageSubtitle: { + maxWidth: 300, + fontFamily: FONTS.regular, + fontSize: 13, + lineHeight: 18, + color: colors.textMuted, }, - header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: SPACING.lg, paddingVertical: SPACING.md }, hero: { marginHorizontal: SPACING.lg, marginTop: SPACING.sm, @@ -1549,24 +1144,6 @@ function createStyles(colors: AppColors) { 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', @@ -1600,8 +1177,6 @@ function createStyles(colors: AppColors) { }, 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, @@ -1620,8 +1195,6 @@ function createStyles(colors: AppColors) { 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, @@ -1638,7 +1211,6 @@ function createStyles(colors: AppColors) { color: colors.info, lineHeight: 18, }, - link: { fontFamily: FONTS.semibold, color: colors.primary, fontSize: 12 }, scroll: { flex: 1 }, card: { marginHorizontal: SPACING.lg, @@ -1699,8 +1271,9 @@ function createStyles(colors: AppColors) { 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' }, + primaryBtn: { flex: 2, height: 52, borderRadius: RADIUS.button, overflow: 'hidden', ...primaryGlow(colors) }, + primaryBtnFill: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + primaryBtnTxt: { fontFamily: FONTS.semibold, fontSize: 15, color: colors.onGradient }, 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 }, @@ -1768,6 +1341,7 @@ function createStyles(colors: AppColors) { color: colors.textSecondary, textAlign: 'center', lineHeight: 18, - }, + } + }); } diff --git a/mobile/app/(tabs)/profile.tsx b/mobile/app/(tabs)/profile.tsx index 1c49fd6..8a7b71a 100644 --- a/mobile/app/(tabs)/profile.tsx +++ b/mobile/app/(tabs)/profile.tsx @@ -23,7 +23,7 @@ import { type AppColors, type ThemePreference, useAppTheme } from '../../src/the 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'; @@ -56,6 +56,9 @@ export default function ProfileScreen() { 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( @@ -277,6 +280,9 @@ export default function ProfileScreen() { const openDeleteAccountModal = () => { setDeletePassword(''); + setDeleteCode(''); + setDeleteStage('request'); + setDeleteAttemptsRemaining(3); setDeleteModalVisible(true); }; @@ -284,9 +290,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 +304,38 @@ 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, [ + { text: t('profile.cancel'), style: 'cancel' }, + { text: 'Adminga murojaat', onPress: () => router.push('/feedback' as any) }, + ]); + } 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')); @@ -316,6 +352,11 @@ export default function ProfileScreen() { }; const menu = [ + { + icon: 'calendar-outline' as const, + label: 'Mening bookinglarim', + onPress: () => router.push('/bookings' as any), + }, { icon: 'chatbubble-ellipses-outline' as const, label: t('profile.feedback', { defaultValue: 'Fikr va shikoyatlar' }), @@ -326,11 +367,6 @@ export default function ProfileScreen() { 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', @@ -765,9 +801,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 +863,16 @@ export default function ProfileScreen() { {isDeletingAccount ? ( ) : ( - {t('profile.deleteNow')} + + {deleteStage === 'request' ? 'Kodni yuborish' : t('profile.deleteNow')} + )} diff --git a/mobile/app/(tabs)/tours.tsx b/mobile/app/(tabs)/tours.tsx new file mode 100644 index 0000000..dd7a2e6 --- /dev/null +++ b/mobile/app/(tabs)/tours.tsx @@ -0,0 +1,442 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + ImageBackground, + Modal, + 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 { homeAPI } from '../../src/utils/api'; +import { extractApiData } from '../../src/utils/auth'; +import { normalizeTours, type HomeTourItem } from '../../src/utils/homeContent'; +import { getJSON, saveJSON } from '../../src/utils/storage'; + +const TOURS_CACHE_KEY = 'agency_tours_cache_v1'; + +// "Qayerga" dropdown — yo'nalishlar. match: tur matnida qidiriladigan kalit so'zlar. +const COUNTRY_OPTIONS: { key: string; label: string; match: string[] }[] = [ + { key: 'all', label: 'Barchasi', match: [] }, + { key: 'uae', label: 'BAA (Dubay)', match: ['baa', 'dubai', 'dubay', 'uae', 'emirat', 'abu dhabi', 'abu-dhabi'] }, + { key: 'turkey', label: 'Turkiya', match: ['turkiya', 'turkey', 'turk', 'antalya', 'istanbul', 'stambul', 'bodrum'] }, + { key: 'egypt', label: 'Misr', match: ['misr', 'egypt', 'sharm', 'hurghada'] }, + { key: 'saudi', label: 'Saudiya Arabistoni', match: ['saudiya', 'saudi', 'makka', 'madina', 'umra', 'umrah', 'hajj', 'haj'] }, + { key: 'thailand', label: 'Tailand', match: ['tailand', 'thailand', 'phuket', 'bangkok', 'pattaya'] }, + { key: 'maldives', label: 'Maldiv orollari', match: ['maldiv', 'maldive'] }, + { key: 'georgia', label: 'Gruziya', match: ['gruziya', 'georgia', 'batumi', 'tbilisi'] }, + { key: 'malaysia', label: 'Malayziya', match: ['malayziya', 'malaysia', 'kuala'] }, + { key: 'indonesia', label: 'Indoneziya (Bali)', match: ['indoneziya', 'indonesia', 'bali', 'jakarta'] }, + { key: 'qatar', label: 'Qatar', match: ['qatar', 'doha'] }, + { key: 'azerbaijan', label: 'Ozarbayjon', match: ['ozarbayjon', 'azerbaijan', 'baku', 'boku'] }, + { key: 'europe', label: 'Yevropa', match: ['yevropa', 'europe', 'parij', 'paris', 'rim', 'rome', 'london', 'barcelona', 'praga'] }, +]; + +export default function ToursScreen() { + const insets = useSafeAreaInsets(); + const safeBottom = Math.max(insets.bottom, 22); + const { colors } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + + const [tours, setTours] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [selectedKey, setSelectedKey] = useState('all'); // dropdown'da tanlangan + const [appliedKey, setAppliedKey] = useState('all'); // "Qidirish" bosilgach qo'llangan + const [pickerOpen, setPickerOpen] = useState(false); + + const selectedLabel = COUNTRY_OPTIONS.find((o) => o.key === selectedKey)?.label || 'Barchasi'; + + const filteredTours = useMemo(() => { + const opt = COUNTRY_OPTIONS.find((o) => o.key === appliedKey); + if (!opt || opt.key === 'all' || opt.match.length === 0) return tours; + return tours.filter((t) => { + const hay = `${t.title} ${t.city} ${t.destinationCountry || ''} ${t.subtitle || ''}`.toLowerCase(); + return opt.match.some((m) => hay.includes(m)); + }); + }, [tours, appliedKey]); + + // Keshdan darrov ko'rsatamiz — birinchi yuklash sekin/uzilsa ham bo'sh qotmaydi. + useEffect(() => { + let active = true; + getJSON(TOURS_CACHE_KEY) + .then((cached) => { + if (active && Array.isArray(cached) && cached.length > 0) { + setTours(cached); + setLoading(false); + } + }) + .catch(() => {}); + return () => { + active = false; + }; + }, []); + + const loadTours = useCallback(async () => { + try { + const payload = extractApiData<{ items?: HomeTourItem[] }>( + await homeAPI.getTours({ agencyOnly: true, limit: 24, page: 1 }) + ); + const items = normalizeTours(payload?.items || []); + // Bo'sh javob kelsa keshdagi mavjud ro'yxatni o'chirmaymiz (xato/uzilishdan himoya). + if (items.length > 0) { + setTours(items); + await saveJSON(TOURS_CACHE_KEY, items); + } else { + setTours((prev) => prev); + } + } catch { + // tarmoq xatosi — keshdagi narsani saqlab qolamiz + } finally { + setLoading(false); + } + }, []); + + // Har safar tab ochilganda yangilab turadi (mount'da bir marta emas). + useFocusEffect( + useCallback(() => { + void loadTours(); + }, [loadTours]) + ); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await loadTours(); + setRefreshing(false); + }, [loadTours]); + + const openTour = useCallback((item: HomeTourItem) => { + router.push({ + pathname: '/tour-details', + params: { tour: encodeURIComponent(JSON.stringify(item)) }, + } as any); + }, []); + + const renderTourCard = (tour: HomeTourItem) => { + const cardContent = ( + <> + + + + {tour.badge} + + + + {tour.rating.toFixed(1)} + + + + + + {tour.duration || 'Tour'} + + {tour.title} + {tour.subtitle || tour.city} + + + {tour.agency?.name || tour.city || 'Agentlik'} + {tour.price || 'Narx so‘rovda'} + + openTour(tour)} activeOpacity={0.84}> + Batafsil + + + + + ); + + return ( + openTour(tour)}> + {tour.imageUrl ? ( + + {cardContent} + + ) : ( + {cardContent} + )} + + ); + }; + + return ( + + + + TravelorAI + router.push('/(tabs)/profile' as any)} activeOpacity={0.82}> + + + + + + Turlar + Tasdiqlangan agentlik turlari bilan tanishing va to‘g‘ridan-to‘g‘ri bron qiling. + + + + Qayerga + + setPickerOpen(true)} activeOpacity={0.84}> + + {selectedLabel} + + + setAppliedKey(selectedKey)} activeOpacity={0.86}> + + Qidirish + + + + + } + > + {loading ? ( + + + Agentlik turlari yuklanmoqda... + + ) : filteredTours.length > 0 ? ( + filteredTours.map(renderTourCard) + ) : appliedKey !== 'all' ? ( + + + Bu yo‘nalish bo‘yicha tur topilmadi + Boshqa davlatni tanlang yoki «Barchasi»ni belgilang. + + ) : ( + + + Hali agentlik turlari yo‘q + Admin tasdiqlagan turlar shu yerda avtomatik ko‘rinadi. + + )} + + + + setPickerOpen(false)}> + setPickerOpen(false)}> + + Qayerga sayohat? + + {COUNTRY_OPTIONS.map((opt) => { + const active = opt.key === selectedKey; + return ( + { + setSelectedKey(opt.key); + setPickerOpen(false); + }} + activeOpacity={0.82} + > + {opt.label} + {active ? : null} + + ); + })} + + + + + + ); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + flex: { flex: 1 }, + 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.sm }, + pageTitle: { fontFamily: FONTS.display, fontSize: 24, color: colors.text, marginBottom: 4 }, + pageSubtitle: { + maxWidth: 320, + fontFamily: FONTS.regular, + fontSize: 13, + lineHeight: 18, + color: colors.textMuted, + }, + filterCard: { + marginHorizontal: SPACING.lg, + marginBottom: SPACING.md, + padding: SPACING.md, + borderRadius: RADIUS.lg, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.borderLight, + gap: SPACING.sm, + }, + filterLabel: { fontFamily: FONTS.semibold, fontSize: 12, color: colors.textMuted }, + filterRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm }, + dropdown: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: SPACING.sm, + height: 46, + borderRadius: RADIUS.md, + backgroundColor: colors.cardMuted, + paddingHorizontal: SPACING.md, + }, + dropdownText: { flex: 1, fontFamily: FONTS.semibold, fontSize: 14, color: colors.text }, + searchBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + height: 46, + paddingHorizontal: SPACING.lg, + borderRadius: RADIUS.md, + backgroundColor: colors.primary, + }, + searchBtnText: { fontFamily: FONTS.semibold, fontSize: 14, color: colors.textInverse }, + modalOverlay: { + flex: 1, + backgroundColor: colors.overlay, + justifyContent: 'center', + paddingHorizontal: SPACING.lg, + }, + modalCard: { + borderRadius: RADIUS.xl, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.borderLight, + padding: SPACING.lg, + }, + modalTitle: { fontFamily: FONTS.display, fontSize: 18, color: colors.text, marginBottom: SPACING.sm }, + countryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.sm, + borderRadius: RADIUS.md, + }, + countryRowActive: { backgroundColor: colors.primaryPale }, + countryText: { fontFamily: FONTS.medium, fontSize: 15, color: colors.text }, + countryTextActive: { fontFamily: FONTS.semibold, color: colors.primary }, + scroll: { flex: 1 }, + stateCard: { + marginHorizontal: SPACING.lg, + marginBottom: SPACING.lg, + borderRadius: 24, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.borderLight, + padding: SPACING.lg, + alignItems: 'center', + gap: SPACING.sm, + }, + stateTitle: { fontFamily: FONTS.display, fontSize: 18, color: colors.text, textAlign: 'center' }, + stateText: { + fontFamily: FONTS.regular, + fontSize: 12, + lineHeight: 18, + color: colors.textMuted, + textAlign: 'center', + }, + 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)' }, + 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: 28, lineHeight: 32, 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', + gap: SPACING.sm, + }, + fromTxt: { fontFamily: FONTS.regular, fontSize: 10, color: 'rgba(255,255,255,0.72)' }, + tourPrice: { fontFamily: FONTS.display, fontSize: 20, color: colors.textInverse }, + detailsBtn: { + minWidth: 110, + height: 36, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.success, + }, + detailsBtnTxt: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.textInverse }, + }); +} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 832b4c8..4ebe162 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,18 +4,20 @@ 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'; import { I18nextProvider } from 'react-i18next'; -import { getItem, KEYS } from '../src/utils/storage'; +import { getItem, saveItem, KEYS } from '../src/utils/storage'; import { AppThemeProvider, useAppTheme } from '../src/theme/app-theme'; import TestModeBanner from '../src/components/TestModeBanner'; import i18n from '../src/i18n'; +import { getExpoPushToken } from '../src/utils/notifications'; +import { authAPI } from '../src/utils/api'; SplashScreen.preventAutoHideAsync().catch(() => {}); @@ -24,8 +26,8 @@ export default function RootLayout() { const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold, - PlusJakartaSans_600SemiBold, - PlusJakartaSans_700Bold, + SpaceGrotesk_600SemiBold, + SpaceGrotesk_700Bold, }); useEffect(() => { @@ -34,15 +36,35 @@ export default function RootLayout() { useEffect(() => { async function restoreSavedLanguage() { + // Mahsulot uz-only (product-strategy): hozircha doim O'zbek. + // Eski qurilmalarda 'en'/'ru' saqlanib qolgan bo'lsa ham uz'ga qaytaramiz. + // i18n kengaytirilganda bu yerga saqlangan tilni tiklash mantig'i qaytariladi. const savedLanguage = await getItem(KEYS.LANGUAGE); - if (savedLanguage === 'uz' || savedLanguage === 'ru' || savedLanguage === 'en') { - await i18n.changeLanguage(savedLanguage); + if (savedLanguage !== 'uz') { + await i18n.changeLanguage('uz'); + await saveItem(KEYS.LANGUAGE, 'uz'); } } restoreSavedLanguage().catch(() => {}); }, []); + // Kirgan foydalanuvchi uchun push tokenni ro'yxatdan o'tkazish (FCM bo'lsa ishlaydi, bo'lmasa jim) + useEffect(() => { + async function registerPush() { + try { + const sessionToken = await getItem(KEYS.TOKEN); + if (!sessionToken) return; + const pushToken = await getExpoPushToken(); + if (pushToken) await authAPI.savePushToken(pushToken); + } catch { + // push muhim emas — xato bo'lsa jim o'tamiz + } + } + const timer = setTimeout(() => registerPush(), 2500); + return () => clearTimeout(timer); + }, []); + useEffect(() => { const t = setTimeout(() => { setBootTimedOut(true); @@ -80,8 +102,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' }, }, }} > @@ -120,12 +142,10 @@ function RootNavigator() { - - - + diff --git a/mobile/app/bookings.tsx b/mobile/app/bookings.tsx new file mode 100644 index 0000000..bd3a1f6 --- /dev/null +++ b/mobile/app/bookings.tsx @@ -0,0 +1,182 @@ +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'; + +const BOOKING_STATUS: Record = { + pending: { label: 'Javob kutilmoqda', tone: 'warn' }, + confirmed: { label: 'Qabul qilingan', tone: 'ok' }, + completed: { label: 'Yakunlangan', tone: 'info' }, + rejected: { label: 'Rad etilgan', tone: 'bad' }, + cancelled: { label: 'Bekor qilingan', tone: 'bad' }, +}; + +function bookingStatus(status?: string) { + return BOOKING_STATUS[status || ''] || { label: status || "Noma'lum", tone: 'info' as const }; +} + +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} + + + {(() => { + const s = bookingStatus(booking.status); + const toneColor = s.tone === 'ok' ? colors.success : s.tone === 'bad' ? colors.error : s.tone === 'info' ? colors.primary : colors.warning; + return {s.label}; + })()} + {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/checkout.tsx b/mobile/app/checkout.tsx deleted file mode 100644 index 8455cbf..0000000 --- a/mobile/app/checkout.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { Text, View } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; - -import { - StitchBottomSpace, - StitchButton, - StitchCard, - StitchHeader, - StitchScrollScreen, - useStitchMobileStyles, -} from '../src/components/stitch/StitchMobile'; -import { FONTS } from '../src/constants/fonts'; -import { SPACING } from '../src/constants/spacing'; - -export default function CheckoutScreen() { - const { colors } = useStitchMobileStyles(); - - return ( - - - - - - - - - - - {"To'lov keyingi bosqichda ulanadi"} - - - {"Hozircha booking so'rovlari agency bilan aloqa orqali tasdiqlanadi."} - - - - - - - Keyingi oqim - - {"User booking yuboradi, agency kabinetida request ko'rinadi, admin esa statusni nazorat qiladi."} - Payment provider tanlangandan keyin bu sahifa real checkoutga ulanadi. - - - - router.back()} /> - router.back()} /> - - - ); -} diff --git a/mobile/app/home-tours.tsx b/mobile/app/home-tours.tsx index a0bf409..b717ffe 100644 --- a/mobile/app/home-tours.tsx +++ b/mobile/app/home-tours.tsx @@ -97,6 +97,12 @@ export default function HomeToursScreen() { {item.title} {item.city || 'Global'} · {item.duration || 'Tour'} + {item.mealPlan || item.hotelCategory ? ( + + {item.hotelCategory ? {item.hotelCategory} : null} + {item.mealPlan ? {item.mealPlan} : null} + + ) : null} @@ -312,6 +318,8 @@ function createStyles(colors: AppColors) { body: { padding: SPACING.md, gap: 6 }, title: { fontFamily: FONTS.display, fontSize: 15, lineHeight: 19, color: colors.text }, city: { fontFamily: FONTS.medium, fontSize: 11, color: colors.textMuted }, + tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, + tag: { fontFamily: FONTS.semibold, fontSize: 10, color: colors.primary, backgroundColor: colors.primaryPale, paddingHorizontal: 8, paddingVertical: 3, borderRadius: RADIUS.full, overflow: 'hidden' }, metaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 2 }, rating: { flexDirection: 'row', alignItems: 'center', gap: 3 }, ratingText: { fontFamily: FONTS.semibold, fontSize: 11, color: colors.text }, diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 54e6856..56d2b43 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,27 +1,57 @@ import { useEffect } from 'react'; -import { View, ActivityIndicator } from 'react-native'; +import { View, StyleSheet, Text } from 'react-native'; import { router } from 'expo-router'; import { getItem, KEYS } from '../src/utils/storage'; -import { useAppTheme } from '../src/theme/app-theme'; +import AuroraBackground from '../src/components/AuroraBackground'; +import Globe3D from '../src/components/Globe3D'; +import AnimatedBrand from '../src/components/AnimatedBrand'; +import { FONTS } from '../src/constants/fonts'; +import { SPACING } from '../src/constants/spacing'; -export default function Index() { - const { colors } = useAppTheme(); +// Splash is always the dark brand moment, so text is forced light regardless of theme. +const LIGHT = '#F7FAFC'; +export default function Index() { useEffect(() => { async function check() { const onboarded = await getItem(KEYS.HAS_ONBOARDED); - if (onboarded === 'true') { - router.replace('/(tabs)'); - } else { + if (onboarded !== 'true') { router.replace('/onboarding'); + return; } + // Home'dan oldin auth ko'rsatiladi (majburiy emas — login ekranida "Skip" bor). + const token = await getItem(KEYS.TOKEN); + router.replace(token ? '/(tabs)' : '/login'); } - check(); + const t = setTimeout(check, 1100); + return () => clearTimeout(t); }, []); return ( - - + + + + + + Your AI Travel Companion + + TravelorAI ); } + +const styles = StyleSheet.create({ + fill: { flex: 1, backgroundColor: '#051F20' }, + center: { justifyContent: 'center', alignItems: 'center' }, + brand: { marginTop: SPACING.xl }, + subtitle: { marginTop: SPACING.sm, fontFamily: FONTS.regular, fontSize: 14, color: 'rgba(247,250,252,0.78)' }, + footer: { + position: 'absolute', + bottom: SPACING.xxl, + alignSelf: 'center', + fontFamily: FONTS.regular, + fontSize: 12, + letterSpacing: 1, + color: 'rgba(247,250,252,0.55)', + }, +}); diff --git a/mobile/app/onboarding.tsx b/mobile/app/onboarding.tsx index eea483c..a6785db 100644 --- a/mobile/app/onboarding.tsx +++ b/mobile/app/onboarding.tsx @@ -1,15 +1,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Animated, Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, 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 { useTranslation } from 'react-i18next'; +import Button from '../src/components/Button'; import { FONTS } from '../src/constants/fonts'; import { RADIUS, SPACING } from '../src/constants/spacing'; +import { aiGlowShadow } from '../src/constants/effects'; import { LANGUAGE_OPTIONS, type Language } from '../src/i18n'; import { type AppColors, useAppTheme } from '../src/theme/app-theme'; -import { KEYS, saveItem } from '../src/utils/storage'; +import { KEYS, getItem, saveItem } from '../src/utils/storage'; const { width } = Dimensions.get('window'); @@ -124,76 +127,85 @@ export default function Onboarding() { const finish = async () => { await saveItem(KEYS.HAS_ONBOARDED, 'true'); - router.replace('/(tabs)'); + // Onboarding'dan keyin auth (majburiy emas — login'da "Skip" bor). + const token = await getItem(KEYS.TOKEN); + router.replace(token ? '/(tabs)' : '/login'); }; const showSkip = current < slides.length - 1; return ( - - item.id} - renderItem={({ item }) => ( - - - + + + item.id} + renderItem={({ item }) => ( + + + + + + {item.title} + {item.desc} - {item.title} - {item.desc} - - )} - /> + )} + /> - - {slides.map((_, index) => ( - - ))} - + + {slides.map((_, index) => ( + + ))} + - - - {current === slides.length - 1 ? t('onboarding.start') : t('onboarding.next')} - + +