feat: implement splash and onboarding flows, update app assets, and add project design documentation

This commit is contained in:
Hamza-Ayed
2026-07-24 04:15:07 +03:00
parent 9f7bc31408
commit 213b459912
208 changed files with 23801 additions and 1066 deletions
@@ -56,4 +56,15 @@ export class MapsController {
addPlace(@Body() body: any) {
return this.maps.addPlace(body);
}
@Post('voice-search')
voiceSearch(
@Body() body: { text: string; country?: string; lat?: number; lng?: number },
) {
if (!body?.text) throw new BadRequestException('text is required');
return this.maps.voiceSearch(body.text, body.country || 'JO', {
lat: body.lat,
lng: body.lng,
});
}
}
+38 -30
View File
@@ -1,18 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
import { GeminiService } from '../../integrations/gemini/gemini.service';
export interface LatLng {
lat: number;
lng: number;
}
export interface RouteResult {
distanceKm: number;
durationMin: number;
provider: string;
}
/**
* وكيل خرائط انطلق (map-saas). التوجيه/الترميز من واجهات انطلق الحقيقية،
* مع رجوع آمن لخط مستقيم (haversine) عند فشل الاتصال حتى لا تتعطّل الرحلات.
@@ -25,6 +20,7 @@ export class MapsService {
constructor(
private readonly config: ConfigService,
private readonly cache: CacheService,
private readonly gemini: GeminiService,
) {}
/**
@@ -56,7 +52,7 @@ export class MapsService {
* **لا يُخزَّن الرجوع لخط مستقيم** — وإلا ثبّتنا تقديراً رديئاً ليوم كامل
* بينما قد يكون انطلق قد عاد للعمل بعد ثوانٍ.
*/
async route(from: LatLng, to: LatLng, country?: string): Promise<RouteResult> {
async route(from: LatLng, to: LatLng, country?: string): Promise<any> {
const key = this.keyFor(country);
if (!key) return this.straightLine(from, to);
@@ -64,7 +60,7 @@ export class MapsService {
MapsService.coordKey(from.lat, from.lng),
MapsService.coordKey(to.lat, to.lng),
);
const hit = await this.cache.get<RouteResult>(cacheKey);
const hit = await this.cache.get<any>(cacheKey);
if (hit) return hit;
try {
@@ -74,12 +70,8 @@ export class MapsService {
const res = await fetch(url, { headers: this.headers(key) });
if (res.ok) {
const json: any = await res.json();
const parsed = MapsService.parseRoute(json);
if (parsed) {
const result: RouteResult = { ...parsed, provider: 'antlaq' };
await this.cache.set(cacheKey, result, TTL.maps);
return result;
}
await this.cache.set(cacheKey, json, TTL.maps);
return json;
} else {
this.logger.warn(`antlaq route ${res.status} — fallback`);
}
@@ -113,6 +105,26 @@ export class MapsService {
return json;
}
/** بحث صوتي ذكي — يحلل النص بـ Gemini ويجلب العناوين من MapSaaS خادمياً */
async voiceSearch(rawText: string, country: string = 'JO', opts?: { lat?: number; lng?: number }) {
let query = rawText.trim();
if (this.gemini.enabled && query.length > 2) {
try {
const parsed = await this.gemini.extractTransferSms(
`أنت مساعد ذكي لتطبيق توصيل الرحلات. استخرج اسم الوجهة والموقع الجغرافي فقط من النص التالي بدون كلمات زائدة: "${query}"`,
'voice',
);
if (parsed?.raw || parsed?.name) {
const cleaned = (parsed.name || parsed.raw || query).replace(/["'{}]/g, '').trim();
if (cleaned.length >= 2) query = cleaned;
}
} catch (e: any) {
this.logger.warn(`Gemini voice search fallback: ${e?.message}`);
}
}
return this.geocodeSearch(query, country, opts);
}
async reverse(lat: number, lng: number, country?: string) {
const key = this.keyFor(country);
const cacheKey = CacheKeys.mapsReverse(lat.toFixed(4), lng.toFixed(4));
@@ -139,28 +151,24 @@ export class MapsService {
return res.json();
}
private straightLine(from: LatLng, to: LatLng): RouteResult {
private straightLine(from: LatLng, to: LatLng): any {
const distanceKm = MapsService.haversineKm(from, to);
const distanceMeters = Math.round(distanceKm * 1000);
const durationSeconds = Math.round((distanceKm / this.avgSpeedKmh) * 3600);
return {
distanceKm: Number(distanceKm.toFixed(3)),
durationMin: Number(((distanceKm / this.avgSpeedKmh) * 60).toFixed(1)),
distance: distanceMeters,
duration: durationSeconds,
bbox: [
Math.min(from.lng, to.lng),
Math.min(from.lat, to.lat),
Math.max(from.lng, to.lng),
Math.max(from.lat, to.lat),
],
tags: ['STRAIGHT_LINE'],
provider: 'straight-line',
};
}
/** يستخرج المسافة/الزمن من أشكال استجابة محتملة (OSRM-like أو مخصّصة). */
private static parseRoute(json: any): { distanceKm: number; durationMin: number } | null {
const r = json?.routes?.[0] ?? json?.data?.routes?.[0] ?? json?.data ?? json;
const meters = r?.distance ?? r?.distanceMeters ?? r?.distance_m;
const seconds = r?.duration ?? r?.durationSeconds ?? r?.duration_s;
if (typeof meters === 'number' && typeof seconds === 'number') {
return {
distanceKm: Number((meters / 1000).toFixed(3)),
durationMin: Number((seconds / 60).toFixed(1)),
};
}
return null;
}
static haversineKm(a: LatLng, b: LatLng): number {
const R = 6371;
+7 -2
View File
@@ -6,7 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, Repository } from 'typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { Trip, TripStatus } from './entities/trip.entity';
import { TripEvent } from './entities/trip-event.entity';
import { MapsService } from '../maps/maps.service';
@@ -103,8 +103,13 @@ export class TripsService {
}
listForRider(tenantId: string, riderId: string): Promise<Trip[]> {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
return this.trips.find({
where: { tenant_id: tenantId, rider_id: riderId },
where: {
tenant_id: tenantId,
rider_id: riderId,
requested_at: MoreThanOrEqual(oneHourAgo),
},
order: { requested_at: 'DESC' },
});
}