feat: add place gate population scripts, map style assets, automated cron sync tasks, and expand dashboard and landing applications
This commit is contained in:
+3
-1
@@ -18,6 +18,8 @@ infrastructure/osm-data/dem_tiles/
|
||||
infrastructure/osm-data/valhalla-work/
|
||||
venv/
|
||||
.venv*/
|
||||
dist/
|
||||
apps/api/dist/
|
||||
apps/web/dist/
|
||||
packages/*/dist/
|
||||
.npm/
|
||||
.npm-cache/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiExcludeController } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ProvisionDeviceKeyDto } from './dto/provision-device-key.dto';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class DeviceAuthController {
|
||||
|
||||
@@ -3,7 +3,12 @@ import { ApiKey } from './api-key.entity';
|
||||
|
||||
export enum TenantPlan {
|
||||
FREE = 'FREE',
|
||||
PAY_AS_YOU_GO = 'PAY_AS_YOU_GO',
|
||||
STARTER = 'STARTER',
|
||||
FLEET = 'FLEET',
|
||||
SCALE = 'SCALE',
|
||||
ENTERPRISE_FLEET = 'ENTERPRISE_FLEET',
|
||||
NATIONAL_GRID = 'NATIONAL_GRID',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
||||
@@ -14,15 +19,25 @@ export enum TenantRole {
|
||||
}
|
||||
|
||||
export const QUOTA_LIMITS: Record<TenantPlan, number> = {
|
||||
[TenantPlan.FREE]: 8000,
|
||||
[TenantPlan.STARTER]: 25000,
|
||||
[TenantPlan.FREE]: 10000,
|
||||
[TenantPlan.PAY_AS_YOU_GO]: 100000000,
|
||||
[TenantPlan.STARTER]: 2000000,
|
||||
[TenantPlan.FLEET]: 5000000,
|
||||
[TenantPlan.SCALE]: 15000000,
|
||||
[TenantPlan.ENTERPRISE_FLEET]: 40000000,
|
||||
[TenantPlan.NATIONAL_GRID]: 200000000,
|
||||
[TenantPlan.PRO]: 100000,
|
||||
[TenantPlan.ENTERPRISE]: 500000,
|
||||
[TenantPlan.ENTERPRISE]: 50000000,
|
||||
};
|
||||
|
||||
export const RATE_LIMITS: Record<TenantPlan, number> = {
|
||||
[TenantPlan.FREE]: 5,
|
||||
[TenantPlan.STARTER]: 100,
|
||||
[TenantPlan.FREE]: 10,
|
||||
[TenantPlan.PAY_AS_YOU_GO]: 500,
|
||||
[TenantPlan.STARTER]: 2100,
|
||||
[TenantPlan.FLEET]: 5400,
|
||||
[TenantPlan.SCALE]: 15000,
|
||||
[TenantPlan.ENTERPRISE_FLEET]: 45000,
|
||||
[TenantPlan.NATIONAL_GRID]: 270000,
|
||||
[TenantPlan.PRO]: 500,
|
||||
[TenantPlan.ENTERPRISE]: 50000,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreateKeyDto } from './dto/management/create-key.dto';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiExcludeController } from '@nestjs/swagger';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('auth')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
|
||||
@@ -3,9 +3,10 @@ import { BillingService } from './billing.service';
|
||||
import { PayMobProvider } from './providers/paymob.provider';
|
||||
import { BinanceProvider } from './providers/binance.provider';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiExcludeController } from '@nestjs/swagger';
|
||||
import { PaymentProvider, PaymentStatus } from './entities/transaction.entity';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('billing')
|
||||
@Controller('billing')
|
||||
export class BillingController {
|
||||
@@ -33,11 +34,26 @@ export class BillingController {
|
||||
const tenantId = req.tenant.id;
|
||||
const plan = body.plan;
|
||||
|
||||
// Mapping prices to plans
|
||||
const pricing = {
|
||||
'STARTER': 29,
|
||||
// Mapping prices to plans (matching public site & sovereign tiers)
|
||||
const pricing: Record<string, number> = {
|
||||
'PAY_AS_YOU_GO': 0,
|
||||
'STARTER': 350,
|
||||
'FLEET': 750,
|
||||
'SCALE': 1400,
|
||||
'ENTERPRISE_FLEET': 2800,
|
||||
'NATIONAL_GRID': 9900,
|
||||
'PRO': 89,
|
||||
};
|
||||
|
||||
if (plan === 'PAY_AS_YOU_GO') {
|
||||
await this.billingService.processSuccessfulPayment(
|
||||
`payg_activate_${Date.now()}`,
|
||||
body.provider || PaymentProvider.PAYMOB,
|
||||
0,
|
||||
{ tenantId, plan: 'PAY_AS_YOU_GO' }
|
||||
);
|
||||
return { checkoutUrl: '/console/billing?status=payg_activated', orderId: `payg_${Date.now()}` };
|
||||
}
|
||||
|
||||
const amount = pricing[plan] || 0;
|
||||
|
||||
@@ -95,7 +111,7 @@ export class BillingController {
|
||||
}
|
||||
|
||||
// Redirect back to dashboard with status
|
||||
const targetUrl = `https://map-dashboard.intaleqapp.com/dashboard.html#billing?payment_status=${query.success === 'true' ? 'success' : 'failed'}&id=${query.id}`;
|
||||
const targetUrl = `https://map-dashboard.intaleqapp.com/console/billing?payment_status=${query.success === 'true' ? 'success' : 'failed'}&id=${query.id}`;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -114,16 +114,21 @@ export class BillingService {
|
||||
await this.tenantRepository.update(tenantId, { plan });
|
||||
|
||||
// Update Subscription
|
||||
const limits = {
|
||||
[TenantPlan.FREE]: 5000,
|
||||
[TenantPlan.STARTER]: 25000,
|
||||
const limits: Record<string, number> = {
|
||||
[TenantPlan.FREE]: 10000,
|
||||
[TenantPlan.PAY_AS_YOU_GO]: 100000000,
|
||||
[TenantPlan.STARTER]: 2000000,
|
||||
[TenantPlan.FLEET]: 5000000,
|
||||
[TenantPlan.SCALE]: 15000000,
|
||||
[TenantPlan.ENTERPRISE_FLEET]: 40000000,
|
||||
[TenantPlan.NATIONAL_GRID]: 200000000,
|
||||
[TenantPlan.PRO]: 100000,
|
||||
[TenantPlan.ENTERPRISE]: 500000,
|
||||
[TenantPlan.ENTERPRISE]: 50000000,
|
||||
};
|
||||
|
||||
const sub = await this.getSubscription(tenantId);
|
||||
sub.plan = plan;
|
||||
sub.monthlyRequestLimit = limits[plan] || 8000;
|
||||
sub.monthlyRequestLimit = limits[plan] || 10000;
|
||||
sub.status = SubscriptionStatus.ACTIVE;
|
||||
sub.currentPeriodStart = new Date();
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export class BinanceProvider {
|
||||
goodsDetail: `Subscription to ${plan} plan for Maps SaaS`,
|
||||
},
|
||||
passThroughInfo: JSON.stringify({ tenantId, plan }),
|
||||
returnUrl: `https://map-dashboard.intaleqapp.com/dashboard.html#billing`,
|
||||
returnUrl: `https://map-dashboard.intaleqapp.com/console/billing`,
|
||||
};
|
||||
|
||||
const signature = this.generateSignature(timestamp, nonce, body);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { CommunityService } from './community.service';
|
||||
import { ReviewContributionDto } from './dto/review-contribution.dto';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller('v1/admin/moderation')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class ModerationController {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { IsString, IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsNumber, IsOptional, IsIn, Min, Max } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const COUNTRY_MAP: Record<string, string> = {
|
||||
jo: 'jordan',
|
||||
jordan: 'jordan',
|
||||
iq: 'iraq',
|
||||
iraq: 'iraq',
|
||||
sy: 'syria',
|
||||
syria: 'syria',
|
||||
eg: 'egypt',
|
||||
egypt: 'egypt',
|
||||
};
|
||||
|
||||
export class SearchQueryDto {
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Search query string' })
|
||||
@@ -28,7 +39,14 @@ export class SearchQueryDto {
|
||||
radius?: number = 20000;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['jordan', 'syria', 'egypt', 'iraq'])
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === 'string') {
|
||||
const clean = value.trim().toLowerCase();
|
||||
return COUNTRY_MAP[clean] || clean;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsIn(['jordan', 'syria', 'egypt', 'iraq'])
|
||||
@ApiPropertyOptional({ description: 'Country filter', enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||
country?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Post, Delete, Body, Query, UseGuards, Req, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiHeader } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiHeader, ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
import { JordanResearchService } from './jordan-research.service';
|
||||
@@ -56,6 +56,7 @@ export class GeocodingController {
|
||||
return this.refinementService.suggestPlace(placeData, userId);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Delete('places')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Delete a place by name or ID' })
|
||||
@@ -76,6 +77,7 @@ export class GeocodingController {
|
||||
throw new HttpException('Name or ID required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('upsert-place')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
|
||||
@@ -83,6 +85,7 @@ export class GeocodingController {
|
||||
return this.geocodingService.upsertPlace(placeData);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('upsert-batch')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Add or Update multiple locations in bulk' })
|
||||
@@ -102,6 +105,7 @@ export class GeocodingController {
|
||||
return this.geocodingService.getAllPlacesGeoJSON();
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('import-boundaries')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
|
||||
@@ -114,12 +118,14 @@ export class GeocodingController {
|
||||
return this.adminBoundariesService.importFromFile(country, filePath);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Get('research/zarqa')
|
||||
@ApiOperation({ summary: 'Generate a research report for Zarqa, Jordan (Sample Data)' })
|
||||
async zarqaResearch() {
|
||||
return this.jordanResearchService.generateZarqaReport();
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('admin/sync-neighborhoods')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
|
||||
@@ -129,6 +135,7 @@ export class GeocodingController {
|
||||
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox, country);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('admin/generate-voronoi')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
|
||||
@@ -137,6 +144,7 @@ export class GeocodingController {
|
||||
return this.adminLinkingService.generateVoronoiNeighborhoods(country);
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('admin/link-places')
|
||||
@UseGuards(AdminGuard)
|
||||
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getElevationMeters } from '../common/gis.utils';
|
||||
export class GeocodingService {
|
||||
private readonly logger = new Logger(GeocodingService.name);
|
||||
|
||||
private readonly DB_TIMEOUT_MS = 1100;
|
||||
private readonly DB_TIMEOUT_MS = 3500;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlaceSyria)
|
||||
@@ -103,10 +103,13 @@ export class GeocodingService {
|
||||
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
|
||||
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
|
||||
|
||||
let queryParams: any[] = [normalizedQuery];
|
||||
const tokens = normalizedQuery.split(/\s+/).filter(t => t.length >= 3);
|
||||
const tokenWildcards = tokens.map(t => `%${t}%`);
|
||||
|
||||
let queryParams: any[] = [normalizedQuery, tokenWildcards];
|
||||
let locationCondition = '';
|
||||
if (hasLocation) {
|
||||
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4`;
|
||||
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($4::float, $3::float), 4326)) <= $5`;
|
||||
queryParams.push(lat, lon, radius);
|
||||
}
|
||||
|
||||
@@ -122,7 +125,7 @@ export class GeocodingService {
|
||||
id, name, name_ar, category,
|
||||
'' as neighbourhood, '' as district, '' as governorate,
|
||||
latitude, longitude, address, region, source, popularity_score,
|
||||
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
|
||||
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($4::float, $3::float), 4326))' : '0'} as distance,
|
||||
GREATEST(
|
||||
similarity(normalized_name, $1),
|
||||
CASE WHEN normalized_name ILIKE $1 || '%' THEN 0.95 ELSE 0.0 END,
|
||||
@@ -133,11 +136,8 @@ export class GeocodingService {
|
||||
WHERE (
|
||||
normalized_name % $1
|
||||
OR normalized_name ILIKE '%' || $1 || '%'
|
||||
-- Token containment (e.g. "المدينة الطبية", "سوبرماركت المدينة", "مخبز جواد")
|
||||
OR (length($1) > 2 AND EXISTS (
|
||||
SELECT 1 FROM unnest(string_to_array($1, ' ')) token
|
||||
WHERE length(token) >= 3 AND normalized_name ILIKE '%' || token || '%'
|
||||
))
|
||||
-- High-performance Token containment using GIN trigram index
|
||||
${tokens.length > 0 ? 'OR normalized_name ILIKE ANY($2)' : ''}
|
||||
-- Generic Category Keywords Mapping (All major amenities)
|
||||
OR (category IN ('mosque', 'place_of_worship') AND ($1 ILIKE '%مسجد%' OR $1 ILIKE '%جامع%' OR $1 ILIKE '%مصلى%'))
|
||||
OR (category IN ('restaurant', 'fast_food', 'food') AND ($1 ILIKE '%مطعم%' OR $1 ILIKE '%شاورما%' OR $1 ILIKE '%وجب%' OR $1 ILIKE '%مشاو%' OR $1 ILIKE '%برغر%'))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { MapRefinementService } from './map-refinement.service';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader, ApiExcludeController } from '@nestjs/swagger';
|
||||
import { CandidateStatus } from './entities/map-candidate.entity';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('map-refinement-places')
|
||||
@Controller('map-refinement/places')
|
||||
export class MapRefinementController {
|
||||
|
||||
+18
-6
@@ -40,17 +40,29 @@ async function bootstrap() {
|
||||
|
||||
// Swagger Documentation Setup
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Jordan Map Platform API')
|
||||
.setDescription('Backend API for map services, routing, and driver telemetry for Jordan')
|
||||
.setTitle('Siro Maps Platform API')
|
||||
.setDescription('Sovereign Geospatial Developer APIs — Geocoding, Places, Routing, Matrix, Vector Tiles & Fleet Telemetry across MENA')
|
||||
.setVersion('1.0')
|
||||
.addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'x-api-key')
|
||||
.addTag('maps')
|
||||
.addTag('geocoding')
|
||||
.addTag('telemetry')
|
||||
.addTag('geocoding', 'Forward geocoding, autocomplete, reverse geocoding, and places search')
|
||||
.addTag('maps', 'Map styles, vector tiles, routing, and fuel economy')
|
||||
.addTag('telemetry', 'Live driver telemetry, fleet tracking, and trajectory analytics')
|
||||
.addTag('heritage', 'Jordan & regional heritage landmarks and cultural sites')
|
||||
.addTag('weather', 'Real-time weather along routes and road condition impact')
|
||||
.addTag('community', 'Driver incident reporting and road hazard alerts')
|
||||
.addTag('tactical', 'Tactical routing, offline packages, and sovereign grid operations')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
// Mount Swagger at both /docs and /api/docs so both direct and proxied requests work seamlessly
|
||||
SwaggerModule.setup('docs', app, document, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customSiteTitle: 'Siro Maps API Docs',
|
||||
});
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customSiteTitle: 'Siro Maps API Docs',
|
||||
});
|
||||
|
||||
const port = process.env.API_PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Post, Query, UseGuards, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -28,6 +28,7 @@ export class MapsController {
|
||||
return this.fuelPricingService.updateMonthlyPrices();
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Post('sync-routes')
|
||||
@ApiOperation({ summary: 'Request GraphHopper routing sync 🔄' })
|
||||
async syncRoutes() {
|
||||
@@ -124,6 +125,23 @@ export class MapsController {
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize internal / external provider URLs to protect internal infrastructure
|
||||
if (styleObj.glyphs && (styleObj.glyphs.includes('protomaps.com') || styleObj.glyphs.includes('cdn.'))) {
|
||||
styleObj.glyphs = 'https://map-saas.intaleqapp.com/fonts/{fontstack}/{range}.pbf';
|
||||
}
|
||||
if (styleObj.sprite && styleObj.sprite.includes('demotiles.maplibre.org')) {
|
||||
styleObj.sprite = 'https://map-saas.intaleqapp.com/sprites/osm-bright';
|
||||
}
|
||||
// Sanitize sources to Siro branded endpoints
|
||||
if (styleObj.sources) {
|
||||
Object.keys(styleObj.sources).forEach(sourceKey => {
|
||||
const s = styleObj.sources[sourceKey];
|
||||
if (s && s.url && (s.url.includes('protomaps.com') || s.url.includes('demotiles'))) {
|
||||
s.url = 'https://map-saas.intaleqapp.com/tiles/v1.json';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(styleObj);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiParam, ApiExcludeController } from '@nestjs/swagger';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { CandidateRoad } from './candidate-road.entity';
|
||||
@@ -7,6 +7,7 @@ import { RoadSegmentStat } from './road-stat.entity';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('map-refinement-roads')
|
||||
@Controller('map-refinement/roads')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiExcludeController, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
import { TenantThrottlerGuard } from '../common/guards/rate-limiter.guard';
|
||||
import { LineOfSightBodyDto, LineOfSightQueryDto } from './dto/line-of-sight.dto';
|
||||
@@ -20,12 +20,7 @@ import { TacticalService } from './tactical.service';
|
||||
import { DemTileService } from './dem-tile.service';
|
||||
import { RoutingPackageService } from './routing-package.service';
|
||||
|
||||
@ApiTags('tactical')
|
||||
@ApiHeader({
|
||||
name: 'x-api-key',
|
||||
description: 'Multi-tenant API Key for Intaleq Maps SaaS',
|
||||
required: true,
|
||||
})
|
||||
@ApiExcludeController()
|
||||
@Controller('tactical')
|
||||
@UseGuards(ApiKeyGuard, TenantThrottlerGuard)
|
||||
export class TacticalController {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { UsageService } from './usage.service';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiExcludeController } from '@nestjs/swagger';
|
||||
|
||||
@ApiExcludeController()
|
||||
@ApiTags('usage')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
|
||||
@@ -36,7 +36,7 @@ export class UsageInterceptor implements NestInterceptor {
|
||||
message: 'Monthly API usage quota exceeded',
|
||||
used: monthlyUsed,
|
||||
limit,
|
||||
upgrade_url: 'https://map-dashbord.intaleqapp.com/#billing'
|
||||
upgrade_url: 'https://map-dashboard.intaleqapp.com/console/billing'
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,13 +111,19 @@ export class UsageService {
|
||||
async getUsageSummary(tenantId: string, plan: string = 'FREE') {
|
||||
const monthlyUsage = await this.getMonthlyUsage(tenantId);
|
||||
|
||||
// Map of plans to limits (synced with interceptor)
|
||||
const QUOTA_LIMITS = {
|
||||
'FREE': 8000,
|
||||
'PRO': 50000,
|
||||
'ENTERPRISE': 1000000
|
||||
// Map of plans to limits (synced with interceptor and tenant entity)
|
||||
const QUOTA_LIMITS: Record<string, number> = {
|
||||
'FREE': 10000,
|
||||
'PAY_AS_YOU_GO': 100000000,
|
||||
'STARTER': 2000000,
|
||||
'FLEET': 5000000,
|
||||
'SCALE': 15000000,
|
||||
'ENTERPRISE_FLEET': 40000000,
|
||||
'NATIONAL_GRID': 200000000,
|
||||
'PRO': 100000,
|
||||
'ENTERPRISE': 50000000,
|
||||
};
|
||||
const monthlyLimit = QUOTA_LIMITS[plan] || 8000;
|
||||
const monthlyLimit = QUOTA_LIMITS[plan] || 10000;
|
||||
|
||||
// Get daily stats and performance metrics
|
||||
const stats = await this.usageRepository
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+15
-264
@@ -1,265 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Intaleq Maps | The Premium Google Maps Alternative</title>
|
||||
<script src="js/lib/tailwind.min.js"></script>
|
||||
<script src="js/lib/lucide.min.js"></script>
|
||||
<script src="js/lib/maplibre-gl.js"></script>
|
||||
<link href="css/lib/maplibre-gl.css" rel="stylesheet" />
|
||||
<style>
|
||||
/* Local Font Definitions */
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('fonts/pjs-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('fonts/pjs-bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('fonts/cairo-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cairo';
|
||||
src: url('fonts/cairo-bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; background-color: #050505; color: #fff; }
|
||||
.glass { background: rgba(20, 20, 25, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
.text-gradient { background: linear-gradient(135deg, #fff 0%, #94a3b8 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.blue-gradient { background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%); }
|
||||
.feature-card:hover { transform: translateY(-5px); border-color: rgba(37, 99, 235, 0.3); }
|
||||
.glow { box-shadow: 0 0 50px -10px rgba(37, 99, 235, 0.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="overflow-x-hidden selection:bg-blue-500/30">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="fixed top-0 w-full z-50 border-b border-white/5 bg-black/50 backdrop-blur-xl">
|
||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl blue-gradient flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<i data-lucide="layers" class="w-6 h-6 text-white text-white"></i>
|
||||
</div>
|
||||
<span class="font-black text-xl tracking-tight">Intaleq <span class="text-blue-500">Maps</span></span>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-8 text-sm font-bold text-slate-400">
|
||||
<a href="#features" class="hover:text-white transition-colors" data-i18n="nav-features">Features</a>
|
||||
<a href="#comparison" class="hover:text-white transition-colors" data-i18n="nav-why">Why Us?</a>
|
||||
<a href="#pricing" class="hover:text-white transition-colors" data-i18n="nav-pricing">Pricing</a>
|
||||
<button onclick="i18n.toggle()" class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/10 hover:bg-white/5 transition-all" data-i18n="lang-toggle">
|
||||
العربية
|
||||
</button>
|
||||
</div>
|
||||
<a href="dashboard.html" class="px-6 py-2.5 rounded-xl bg-white text-black font-black text-sm hover:scale-105 transition-all" data-i18n="nav-launch">Launch Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="relative pt-40 pb-20 px-6 overflow-hidden">
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full pointer-events-none"></div>
|
||||
<div class="max-w-5xl mx-auto text-center relative z-10">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-black uppercase tracking-widest mb-8" data-i18n="hero-badge">
|
||||
<i data-lucide="sparkles" class="w-3.5 h-3.5"></i> Now with 3D Buildings in Jordan & Syria
|
||||
</div>
|
||||
<h1 class="text-6xl md:text-8xl font-black tracking-tighter mb-8 leading-[0.9] text-gradient" data-i18n="hero-title">
|
||||
The Map API That <br> <span class="text-blue-500">Doesn't Break</span> The Bank.
|
||||
</h1>
|
||||
<p class="text-xl text-slate-400 max-w-2xl mx-auto mb-12 font-medium leading-relaxed" data-i18n="hero-desc">
|
||||
Build premium location-based apps with high-fidelity vector tiles, optimized routing, and 3D buildings. 85% cheaper than Google Maps.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a href="dashboard.html" class="w-full sm:w-auto px-8 py-4 rounded-2xl blue-gradient text-white font-black text-lg shadow-2xl shadow-blue-500/30 hover:scale-105 transition-all flex items-center justify-center gap-3" data-i18n="hero-cta-start">
|
||||
Start Building Free <i data-lucide="arrow-right" class="w-5 h-5"></i>
|
||||
</a>
|
||||
<a href="#comparison" class="w-full sm:w-auto px-8 py-4 rounded-2xl glass text-white font-black text-lg hover:bg-white/5 transition-all text-center" data-i18n="hero-cta-view">
|
||||
View Comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-6 pb-40">
|
||||
<div class="max-w-6xl mx-auto glass rounded-[3rem] p-4 glow relative overflow-hidden group">
|
||||
<div id="hero-map" class="rounded-[2rem] overflow-hidden bg-slate-900 aspect-video relative">
|
||||
<!-- Live Map Context -->
|
||||
</div>
|
||||
<!-- Overlay Info -->
|
||||
<div class="absolute bottom-12 left-12 max-w-md pointer-events-none">
|
||||
<div class="glass p-6 rounded-3xl border-blue-500/20 backdrop-blur-md">
|
||||
<h3 class="text-xl font-black mb-2 flex items-center gap-2 italic">
|
||||
<i data-lucide="box" class="text-blue-400 w-5 h-5"></i> REAL-TIME 3D
|
||||
</h3>
|
||||
<p class="text-sm text-slate-400 font-medium">Render every skyscraper in Amman and every street in Damascus with sub-meter precision and beautiful 3D extrusions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Comparison Table -->
|
||||
<section id="comparison" class="py-24 px-6 bg-white/[0.01]">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-black mb-4">Numbers Don't Lie.</h2>
|
||||
<p class="text-slate-400 font-medium">Switch from Google Maps and save thousands every month.</p>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-[2.5rem] overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-white/5 bg-blue-600/5">
|
||||
<th class="px-8 py-6 text-sm font-black uppercase tracking-widest text-slate-400">Feature</th>
|
||||
<th class="px-8 py-6 text-sm font-black uppercase tracking-widest text-slate-400 text-center">Google Maps</th>
|
||||
<th class="px-8 py-6 text-sm font-black uppercase tracking-widest text-blue-400 text-center bg-blue-500/10">Intaleq Maps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<tr>
|
||||
<td class="px-8 py-6 font-bold">50,000 API Requests</td>
|
||||
<td class="px-8 py-6 text-center text-red-400 font-medium">$350 / mo</td>
|
||||
<td class="px-8 py-6 text-center text-emerald-400 font-black bg-blue-500/5">$40 / mo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-8 py-6 font-bold">Map Tiles (Vector)</td>
|
||||
<td class="px-8 py-6 text-center text-slate-400">Charged per load</td>
|
||||
<td class="px-8 py-6 text-center text-white font-black bg-blue-500/5">UNLIMITED / FREE</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-8 py-6 font-bold">3D Buildings Data</td>
|
||||
<td class="px-8 py-6 text-center text-slate-400">Limited in Levant</td>
|
||||
<td class="px-8 py-6 text-center text-white font-black bg-blue-500/5">Full Coverage (Jordan/Syria)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-8 py-6 font-bold">Bill Transparency</td>
|
||||
<td class="px-8 py-6 text-center text-slate-400">Hidden Costs</td>
|
||||
<td class="px-8 py-6 text-center text-white font-black bg-blue-500/5">Fixed Pricing</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-8 text-center text-xs text-slate-500 font-bold uppercase tracking-widest leading-loose">
|
||||
* Based on public pricing as of April 2026. Savings calculated on equivalent request volume.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section id="features" class="py-40 px-6">
|
||||
<div class="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="glass p-10 rounded-[2.5rem] feature-card transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400 mb-8">
|
||||
<i data-lucide="zap" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black mb-4" data-i18n="feat-latency-title">Zero Latency</h3>
|
||||
<p class="text-slate-400 font-medium leading-relaxed" data-i18n="feat-latency-desc">Our infrastructure is optimized for MENA region, ensuring map tiles load in under 200ms anywhere in Amman or Damascus.</p>
|
||||
</div>
|
||||
<div class="glass p-10 rounded-[2.5rem] feature-card transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-400 mb-8">
|
||||
<i data-lucide="navigation" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black mb-4" data-i18n="feat-routing-title">Smart Routing</h3>
|
||||
<p class="text-slate-400 font-medium leading-relaxed" data-i18n="feat-routing-desc">Enterprise-grade routing engine with support for alternative paths, traffic awareness, and custom road constraints.</p>
|
||||
</div>
|
||||
<div class="glass p-10 rounded-[2.5rem] feature-card transition-all duration-300">
|
||||
<div class="w-14 h-14 rounded-2xl bg-violet-500/10 flex items-center justify-center text-violet-400 mb-8">
|
||||
<i data-lucide="shield-check" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black mb-4" data-i18n="feat-geocoding-title">Local Geocoding</h3>
|
||||
<p class="text-slate-400 font-medium leading-relaxed" data-i18n="feat-geocoding-desc">Highly accurate search for local landmarks, neighborhoods, and buildings often missing from global providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing (Simplified) -->
|
||||
<section id="pricing" class="py-24 px-6 relative">
|
||||
<div class="max-w-3xl mx-auto glass p-12 rounded-[3rem] text-center border-blue-500/30 overflow-hidden">
|
||||
<div class="absolute -top-24 -right-24 w-64 h-64 bg-blue-500/10 blur-[80px] rounded-full"></div>
|
||||
<h2 class="text-4xl font-black mb-4">Simple, Transparent Pricing.</h2>
|
||||
<div class="my-10">
|
||||
<h4 class="text-6xl font-black text-blue-500">$40<span class="text-xl text-slate-500 font-bold tracking-normal italic"> / 50k requests</span></h4>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-10 text-slate-400 font-bold">
|
||||
<li>Everything in Free Tier included</li>
|
||||
<li>Commercial License for Fleet Tracking</li>
|
||||
<li>Premium 3D Map Tiles (Unlimited)</li>
|
||||
<li>24/7 Priority Support</li>
|
||||
</ul>
|
||||
<a href="dashboard.html#billing" class="inline-flex btn px-12 py-4 rounded-2xl blue-gradient text-white font-black text-lg shadow-xl shadow-blue-500/20 hover:scale-105 transition-all">Get Professional Access</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-20 border-t border-white/5 text-center">
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<div class="w-8 h-8 rounded-lg blue-gradient flex items-center justify-center text-white">
|
||||
<i data-lucide="layers" class="w-5 h-5 text-white"></i>
|
||||
</div>
|
||||
<span class="font-black text-lg">Intaleq <span class="text-blue-500">Maps</span></span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm font-bold tracking-widest uppercase">Intaleq Software Solutions © 2026</p>
|
||||
</footer>
|
||||
|
||||
<script src="js/i18n.js"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// Initialize Cinematic Hero Map
|
||||
try {
|
||||
const map = new maplibregl.Map({
|
||||
container: 'hero-map',
|
||||
style: '/api/maps/styles/obsidian', // Using our local premium style
|
||||
center: [35.9285, 31.9454], // Amman
|
||||
zoom: 14,
|
||||
pitch: 60,
|
||||
bearing: -20,
|
||||
interactive: false,
|
||||
antialias: true
|
||||
});
|
||||
|
||||
// Smooth rotation for cinematic feel
|
||||
let angle = -20;
|
||||
function rotate() {
|
||||
angle += 0.05;
|
||||
map.setBearing(angle % 360);
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
map.on('load', () => {
|
||||
rotate();
|
||||
// Add 3D building layer if available in style
|
||||
if (map.getSource('openmaptiles')) {
|
||||
const layers = map.getStyle().layers;
|
||||
const labelLayerId = layers.find(l => l.type === 'symbol' && l.layout['text-field'])?.id;
|
||||
|
||||
map.addLayer({
|
||||
'id': '3d-buildings',
|
||||
'source': 'openmaptiles',
|
||||
'source-layer': 'building',
|
||||
'type': 'fill-extrusion',
|
||||
'minzoom': 15,
|
||||
'paint': {
|
||||
'fill-extrusion-color': '#334155',
|
||||
'fill-extrusion-height': ['get', 'render_height'],
|
||||
'fill-extrusion-base': ['get', 'render_min_height'],
|
||||
'fill-extrusion-opacity': 0.6
|
||||
}
|
||||
}, labelLayerId);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Hero map initialization skipped (offline/style error)');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0d110e" />
|
||||
<meta name="description" content="Siro Maps developer platform: maps, search, routing, spatial data and SDKs for web and mobile." />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<title>SIRO Maps — Sovereign Geospatial Platform</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
|
||||
Generated
+2806
-330
File diff suppressed because it is too large
Load Diff
@@ -11,20 +11,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"firebase": "^12.19.0",
|
||||
"lucide-react": "latest",
|
||||
"maplibre-gl": "^5.1.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^2.15.1"
|
||||
"react-router-dom": "^7.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -34,6 +33,6 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "SIRO Maps API",
|
||||
"description": "Safe starter collection for maps, search and geocoding. Set the collection variables before sending requests.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"auth": {
|
||||
"type": "apikey",
|
||||
"apikey": [
|
||||
{ "key": "key", "value": "x-api-key", "type": "string" },
|
||||
{ "key": "value", "value": "{{apiKey}}", "type": "string" },
|
||||
{ "key": "in", "value": "header", "type": "string" }
|
||||
]
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "baseUrl", "value": "http://localhost:3200/api", "type": "string" },
|
||||
{ "key": "apiKey", "value": "YOUR_API_KEY", "type": "string" }
|
||||
],
|
||||
"item": [
|
||||
{ "name": "Search places", "request": { "method": "GET", "header": [], "url": "{{baseUrl}}/geocoding/search?q=Amman&country=jordan", "description": "Forward geocoding and local place search." } },
|
||||
{ "name": "Autocomplete", "request": { "method": "GET", "header": [], "url": "{{baseUrl}}/geocoding/autocomplete?q=Am&country=jordan", "description": "Fast suggestions for search-as-you-type experiences." } },
|
||||
{ "name": "Reverse geocode", "request": { "method": "GET", "header": [], "url": "{{baseUrl}}/geocoding/reverse?lat=31.9539&lng=35.9106", "description": "Resolve coordinates to the closest available address." } },
|
||||
{ "name": "Map style", "request": { "method": "GET", "header": [], "url": "{{baseUrl}}/maps/style.json?theme=obsidian", "description": "Load the MapLibre-compatible style document." } }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,868 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Intaleq Sovereign 3D Topographic & Terrain Style (\u062e\u0631\u064a\u0637\u0629 \u0627\u0644\u062a\u0636\u0627\u0631\u064a\u0633 \u062b\u0644\u0627\u062b\u064a\u0629 \u0627\u0644\u0623\u0628\u0639\u0627\u062f \u0627\u0644\u0633\u064a\u0627\u062f\u064a\u0629)",
|
||||
"metadata": {
|
||||
"brand": "Intaleq",
|
||||
"version": "3.5.0-terrain-3d",
|
||||
"description": "High-fidelity Sovereign 3D Topographic Style with DEM terrain displacement, dynamic hillshading, contour lines, and 3D architectural extrusions"
|
||||
},
|
||||
"center": [
|
||||
35.4337,
|
||||
29.5763
|
||||
],
|
||||
"zoom": 12.5,
|
||||
"pitch": 65,
|
||||
"bearing": -35,
|
||||
"glyphs": "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf",
|
||||
"sources": {
|
||||
"terrain-dem": {
|
||||
"type": "raster-dem",
|
||||
"tiles": [
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
|
||||
"https://tiles.intaleqapp.com/raster_dem/{z}/{x}/{y}.png"
|
||||
],
|
||||
"encoding": "terrarium",
|
||||
"tileSize": 256,
|
||||
"maxzoom": 15
|
||||
},
|
||||
"esri-satellite": {
|
||||
"type": "raster",
|
||||
"tiles": [
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
"https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
|
||||
],
|
||||
"tileSize": 256,
|
||||
"maxzoom": 19,
|
||||
"attribution": "© Esri, DigitalGlobe, GeoEye, Earthstar Geographics"
|
||||
},
|
||||
"jordan_contours": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/jordan_contours/{z}/{x}/{y}"
|
||||
],
|
||||
"minzoom": 8,
|
||||
"maxzoom": 16
|
||||
},
|
||||
"local-osm-polygons": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/planet_osm_polygon/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14,
|
||||
"attribution": "© Intaleq | © OpenStreetMap contributors"
|
||||
},
|
||||
"local-osm-lines": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/planet_osm_line/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"local-osm-points": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/planet_osm_point/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"places_jordan": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/places_jordan/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"overture_buildings": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/overture_building/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 16
|
||||
},
|
||||
"approved_roads": {
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
|
||||
],
|
||||
"minzoom": 8,
|
||||
"maxzoom": 18
|
||||
}
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"id": "topo-background",
|
||||
"type": "background",
|
||||
"paint": {
|
||||
"background-color": "#EFECE6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "satellite-base-layer",
|
||||
"type": "raster",
|
||||
"source": "esri-satellite",
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"raster-opacity": 1.0,
|
||||
"raster-saturation": 0.2,
|
||||
"raster-contrast": 0.08
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "terrain-3d-hillshading",
|
||||
"type": "hillshade",
|
||||
"source": "terrain-dem",
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"hillshade-illumination-direction": 315,
|
||||
"hillshade-illumination-anchor": "viewport",
|
||||
"hillshade-shadow-color": "#111111",
|
||||
"hillshade-highlight-color": "#ffffff",
|
||||
"hillshade-accent-color": "#332211",
|
||||
"hillshade-exaggeration": 0.45
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "natural-water-poly",
|
||||
"type": "fill",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"filter": [
|
||||
"any",
|
||||
[
|
||||
"==",
|
||||
"natural",
|
||||
"water"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"water",
|
||||
"lake"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"waterway",
|
||||
"riverbank"
|
||||
]
|
||||
],
|
||||
"paint": {
|
||||
"fill-color": "#0284c7",
|
||||
"fill-opacity": 0.85
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "natural-wood-forest",
|
||||
"type": "fill",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"filter": [
|
||||
"any",
|
||||
[
|
||||
"==",
|
||||
"natural",
|
||||
"wood"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"landuse",
|
||||
"forest"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"leisure",
|
||||
"nature_reserve"
|
||||
]
|
||||
],
|
||||
"paint": {
|
||||
"fill-color": "#4d7c0f",
|
||||
"fill-opacity": 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "natural-sand-dune",
|
||||
"type": "fill",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"filter": [
|
||||
"any",
|
||||
[
|
||||
"==",
|
||||
"natural",
|
||||
"sand"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"natural",
|
||||
"desert"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"natural",
|
||||
"scree"
|
||||
]
|
||||
],
|
||||
"paint": {
|
||||
"fill-color": "#ea580c",
|
||||
"fill-opacity": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wadis-waterways",
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"waterway",
|
||||
"river",
|
||||
"stream",
|
||||
"canal",
|
||||
"wadi"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#0284c7",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
8,
|
||||
1,
|
||||
14,
|
||||
3.5
|
||||
],
|
||||
"line-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contour-minor-lines",
|
||||
"type": "line",
|
||||
"source": "jordan_contours",
|
||||
"source-layer": "jordan_contours",
|
||||
"minzoom": 11,
|
||||
"layout": {
|
||||
"visibility": "visible",
|
||||
"line-join": "round",
|
||||
"line-cap": "round"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "#a88358",
|
||||
"line-width": 0.75,
|
||||
"line-opacity": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contour-major-lines",
|
||||
"type": "line",
|
||||
"source": "jordan_contours",
|
||||
"source-layer": "jordan_contours",
|
||||
"minzoom": 9,
|
||||
"filter": [
|
||||
"==",
|
||||
"type",
|
||||
"index"
|
||||
],
|
||||
"layout": {
|
||||
"visibility": "visible",
|
||||
"line-join": "round",
|
||||
"line-cap": "round"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "#784a28",
|
||||
"line-width": 1.4,
|
||||
"line-opacity": 0.75
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-casing",
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"motorway",
|
||||
"trunk",
|
||||
"primary",
|
||||
"secondary"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
8,
|
||||
2,
|
||||
14,
|
||||
6
|
||||
],
|
||||
"line-opacity": 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-core-highways",
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"motorway",
|
||||
"trunk"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#d97706",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
8,
|
||||
1.2,
|
||||
14,
|
||||
4.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-core-primary",
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"primary",
|
||||
"secondary"
|
||||
],
|
||||
"paint": {
|
||||
"line-color": "#f59e0b",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
9,
|
||||
1,
|
||||
14,
|
||||
3.2
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-core-minor",
|
||||
"type": "line",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"residential",
|
||||
"service",
|
||||
"unclassified",
|
||||
"living_street"
|
||||
],
|
||||
"minzoom": 13.5,
|
||||
"paint": {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
13.5,
|
||||
0.9,
|
||||
16,
|
||||
2.5
|
||||
],
|
||||
"line-opacity": 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-labels-highways",
|
||||
"type": "symbol",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"motorway",
|
||||
"trunk",
|
||||
"primary"
|
||||
],
|
||||
"minzoom": 12,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Bold",
|
||||
"Open Sans Bold"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
12,
|
||||
10,
|
||||
15,
|
||||
12,
|
||||
18,
|
||||
14
|
||||
],
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map",
|
||||
"text-pitch-alignment": "map",
|
||||
"text-keep-upright": true,
|
||||
"text-letter-spacing": 0.05,
|
||||
"text-padding": 24,
|
||||
"symbol-spacing": 700,
|
||||
"text-max-angle": 20,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#1e293b",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.95)",
|
||||
"text-halo-width": 2.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-labels-major",
|
||||
"type": "symbol",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"secondary",
|
||||
"tertiary"
|
||||
],
|
||||
"minzoom": 13.5,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Bold",
|
||||
"Open Sans Bold"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
13.5,
|
||||
9.5,
|
||||
16,
|
||||
11,
|
||||
18,
|
||||
13
|
||||
],
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map",
|
||||
"text-pitch-alignment": "map",
|
||||
"text-keep-upright": true,
|
||||
"text-letter-spacing": 0.04,
|
||||
"text-padding": 20,
|
||||
"symbol-spacing": 600,
|
||||
"text-max-angle": 20,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#334155",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.9)",
|
||||
"text-halo-width": 2.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-labels-minor",
|
||||
"type": "symbol",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"filter": [
|
||||
"in",
|
||||
"highway",
|
||||
"residential",
|
||||
"unclassified"
|
||||
],
|
||||
"minzoom": 16,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Regular",
|
||||
"Open Sans Regular"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
16,
|
||||
9.5,
|
||||
18,
|
||||
11.5
|
||||
],
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map",
|
||||
"text-pitch-alignment": "map",
|
||||
"text-keep-upright": true,
|
||||
"text-letter-spacing": 0.04,
|
||||
"text-padding": 20,
|
||||
"symbol-spacing": 600,
|
||||
"text-max-angle": 18,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#475569",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.9)",
|
||||
"text-halo-width": 1.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "approved-road-labels",
|
||||
"type": "symbol",
|
||||
"source": "approved_roads",
|
||||
"source-layer": "approved_roads",
|
||||
"minzoom": 16.5,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Bold",
|
||||
"Open Sans Bold"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
16.5,
|
||||
9,
|
||||
18,
|
||||
11.5
|
||||
],
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map",
|
||||
"text-pitch-alignment": "map",
|
||||
"text-keep-upright": true,
|
||||
"text-letter-spacing": 0.05,
|
||||
"text-padding": 20,
|
||||
"symbol-spacing": 650,
|
||||
"text-max-angle": 20,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#1e293b",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.95)",
|
||||
"text-halo-width": 2.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3d-buildings-ground-shadows",
|
||||
"type": "fill",
|
||||
"source": "overture_buildings",
|
||||
"source-layer": "overture_building",
|
||||
"minzoom": 13.5,
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"fill-color": "#0f172a",
|
||||
"fill-opacity": 0.45,
|
||||
"fill-translate": [
|
||||
12,
|
||||
4
|
||||
],
|
||||
"fill-translate-anchor": "map"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3d-buildings-extrusion",
|
||||
"type": "fill-extrusion",
|
||||
"source": "overture_buildings",
|
||||
"source-layer": "overture_building",
|
||||
"minzoom": 13.5,
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"fill-extrusion-color": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"height"
|
||||
],
|
||||
12
|
||||
],
|
||||
0,
|
||||
"#e7e5e4",
|
||||
30,
|
||||
"#d6d3d1",
|
||||
70,
|
||||
"#a8a29e",
|
||||
150,
|
||||
"#78716c"
|
||||
],
|
||||
"fill-extrusion-height": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"height"
|
||||
],
|
||||
[
|
||||
"*",
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"num_floors"
|
||||
],
|
||||
3
|
||||
],
|
||||
3.5
|
||||
]
|
||||
],
|
||||
"fill-extrusion-base": 0,
|
||||
"fill-extrusion-opacity": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mountain-peaks-points",
|
||||
"type": "circle",
|
||||
"source": "local-osm-points",
|
||||
"source-layer": "planet_osm_point",
|
||||
"filter": [
|
||||
"==",
|
||||
"natural",
|
||||
"peak"
|
||||
],
|
||||
"minzoom": 10,
|
||||
"paint": {
|
||||
"circle-radius": 4.5,
|
||||
"circle-color": "#991b1b",
|
||||
"circle-stroke-width": 1.5,
|
||||
"circle-stroke-color": "#ffffff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mountain-peaks-labels",
|
||||
"type": "symbol",
|
||||
"source": "local-osm-points",
|
||||
"source-layer": "planet_osm_point",
|
||||
"filter": [
|
||||
"==",
|
||||
"natural",
|
||||
"peak"
|
||||
],
|
||||
"minzoom": 10,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"format",
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
"\u0642\u0645\u0629 \u062c\u0628\u0644\u064a\u0629"
|
||||
],
|
||||
{},
|
||||
"\n\u25b2 ",
|
||||
{
|
||||
"font-scale": 0.8
|
||||
},
|
||||
[
|
||||
"concat",
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"ele"
|
||||
],
|
||||
""
|
||||
],
|
||||
" \u0645"
|
||||
],
|
||||
{
|
||||
"font-scale": 0.75
|
||||
}
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Regular",
|
||||
"Open Sans Regular"
|
||||
],
|
||||
"text-size": 11.5,
|
||||
"text-offset": [
|
||||
0,
|
||||
1.2
|
||||
],
|
||||
"text-anchor": "top",
|
||||
"text-max-width": 10
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#451a03",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jordan-city-labels",
|
||||
"type": "symbol",
|
||||
"source": "places_jordan",
|
||||
"source-layer": "places_jordan",
|
||||
"filter": [
|
||||
"any",
|
||||
[
|
||||
"==",
|
||||
"category",
|
||||
"city"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"category",
|
||||
"town"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"category",
|
||||
"governorate"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"category",
|
||||
"capital"
|
||||
]
|
||||
],
|
||||
"minzoom": 6,
|
||||
"maxzoom": 11,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name_ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Arabic Bold",
|
||||
"Open Sans Bold"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
6,
|
||||
10,
|
||||
9,
|
||||
12,
|
||||
11,
|
||||
14
|
||||
],
|
||||
"text-padding": 24,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false,
|
||||
"text-transform": "uppercase",
|
||||
"text-letter-spacing": 0.05
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#1c1917",
|
||||
"text-halo-color": "#fafaf9",
|
||||
"text-halo-width": 2.2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -84,6 +84,17 @@
|
||||
"https://tiles.intaleqapp.com/places_iraq/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"carto-voyager": {
|
||||
"type": "raster",
|
||||
"tiles": [
|
||||
"https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
|
||||
"https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
|
||||
"https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png"
|
||||
],
|
||||
"tileSize": 256,
|
||||
"maxzoom": 19,
|
||||
"attribution": "© CARTO, © OpenStreetMap contributors"
|
||||
}
|
||||
},
|
||||
"layers": [
|
||||
@@ -94,6 +105,14 @@
|
||||
"background-color": "#FAF6F0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "carto-voyager-base",
|
||||
"type": "raster",
|
||||
"source": "carto-voyager",
|
||||
"paint": {
|
||||
"raster-opacity": 0.94
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "admin-boundary-national",
|
||||
"type": "line",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+28
-69
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
@@ -8,82 +8,41 @@ import Analytics from './pages/Analytics';
|
||||
import Billing from './pages/Billing';
|
||||
import Documentation from './pages/Documentation';
|
||||
import Playground from './pages/Playground';
|
||||
import PublicHome from './pages/PublicHome';
|
||||
import { LanguageProvider } from './i18n';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
rateLimit: number;
|
||||
allowedOrigins: string[];
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
export interface ApiKey { id: string; key: string; name: string; isActive: boolean; rateLimit: number; allowedOrigins: string[]; lastUsedAt: string | null; }
|
||||
export interface Tenant { id: string; name: string; email: string; }
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
function ConsoleLayout() {
|
||||
const { user, ready } = useAuth();
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoading(true); setConnectionError(false);
|
||||
const tenantRes = await axios.get('/api/auth/management/me');
|
||||
setTenant(tenantRes.data);
|
||||
|
||||
if (tenantRes.data && tenantRes.data.id) {
|
||||
const keysRes = await axios.get(`/api/auth/management/keys/${tenantRes.data.id}`);
|
||||
if (Array.isArray(keysRes.data)) {
|
||||
setKeys(keysRes.data);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch dashboard data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const keysRes = await axios.get('/api/auth/management/keys');
|
||||
setKeys(Array.isArray(keysRes.data) ? keysRes.data : []);
|
||||
} catch (error) {
|
||||
console.warn('Dashboard session is not available', error); setConnectionError(true);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { if (ready && user) void fetchData(); else if (ready) { setLoading(false); setConnectionError(true); } }, [ready, user]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [location.pathname]);
|
||||
return <div className="console-layout"><Sidebar /><div className="console-main"><Header tenant={tenant} /><main className="console-content"><Routes>
|
||||
<Route index element={<DashboardHome tenant={tenant} keys={keys} loading={loading} connectionError={connectionError} onRefresh={fetchData} />} />
|
||||
<Route path="analytics" element={<Analytics />} /><Route path="billing" element={<Billing />} />
|
||||
<Route path="documentation" element={<Documentation />} /><Route path="playground" element={<Playground apiKeys={keys} />} />
|
||||
<Route path="*" element={<Navigate to="/console" replace />} />
|
||||
</Routes></main></div></div>;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Scroll to top on route change
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Sidebar />
|
||||
<div className="flex-1 overflow-y-auto bg-gradient-to-br from-[#0a0a0b] via-[#0f1115] to-[#0a0a0b]">
|
||||
<Header />
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<DashboardHome
|
||||
tenant={tenant}
|
||||
keys={keys}
|
||||
loading={loading}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/billing" element={<Billing />} />
|
||||
<Route path="/documentation" element={<Documentation />} />
|
||||
<Route path="/playground" element={<Playground apiKeys={keys} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default function App() {
|
||||
return <LanguageProvider><AuthProvider><Routes><Route path="/" element={<PublicHome />} /><Route path="/console/*" element={<ConsoleLayout />} /><Route path="*" element={<Navigate to="/" replace />} /></Routes></AuthProvider></LanguageProvider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { getApp, getApps, initializeApp } from 'firebase/app';
|
||||
import { GoogleAuthProvider, getAuth, onAuthStateChanged, signInWithPopup, signOut, type User } from 'firebase/auth';
|
||||
import axios from 'axios';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAwybkM9FFkF2KRM0bFNAaEPNPbMZBWFn8',
|
||||
authDomain: 'intaleq-map.firebaseapp.com',
|
||||
projectId: 'intaleq-map',
|
||||
storageBucket: 'intaleq-map.firebasestorage.app',
|
||||
messagingSenderId: '1052695318500',
|
||||
appId: '1:1052695318500:web:7913981536524a630401f1',
|
||||
};
|
||||
const firebaseApp = getApps().length ? getApp() : initializeApp(firebaseConfig);
|
||||
const firebaseAuth = getAuth(firebaseApp);
|
||||
|
||||
type AuthValue = { user: User | null; ready: boolean; login: () => Promise<void>; logout: () => Promise<void> };
|
||||
const AuthContext = createContext<AuthValue | null>(null);
|
||||
|
||||
export function AuthProvider({children}:{children:ReactNode}) {
|
||||
const [user,setUser]=useState<User|null>(null); const [ready,setReady]=useState(false);
|
||||
useEffect(()=>onAuthStateChanged(firebaseAuth,async next=>{setUser(next);if(next){axios.defaults.headers.common.Authorization=`Bearer ${await next.getIdToken()}`}else{delete axios.defaults.headers.common.Authorization}setReady(true)}),[]);
|
||||
const value=useMemo<AuthValue>(()=>({user,ready,login:async()=>{await signInWithPopup(firebaseAuth,new GoogleAuthProvider())},logout:async()=>{await signOut(firebaseAuth)}}),[user,ready]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
export function useAuth(){const value=useContext(AuthContext);if(!value)throw new Error('useAuth must be used inside AuthProvider');return value}
|
||||
@@ -1,40 +1,10 @@
|
||||
import { Search, Bell, HelpCircle, ChevronDown } from 'lucide-react';
|
||||
import { Languages, LogIn, LogOut, Search } from 'lucide-react';
|
||||
import type { Tenant } from '../App';
|
||||
import { useLanguage } from '../i18n';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<header className="h-20 flex items-center justify-between px-8 bg-transparent sticky top-0 z-10">
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for keys, documentation, or settings..."
|
||||
className="w-full bg-slate-900/50 border border-slate-800 rounded-xl py-2.5 pl-12 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4 border-r border-slate-800 pr-6 mr-1">
|
||||
<button className="text-slate-400 hover:text-white transition-colors relative">
|
||||
<Bell size={20} />
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full border-2 border-bg-deep"></span>
|
||||
</button>
|
||||
<button className="text-slate-400 hover:text-white transition-colors">
|
||||
<HelpCircle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="flex items-center gap-2 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-slate-700 to-slate-600 border border-slate-700 overflow-hidden">
|
||||
<div className="w-full h-full flex items-center justify-center text-[10px] font-bold text-slate-300">HA</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors">Hamza Admin</span>
|
||||
<ChevronDown size={14} className="text-slate-500 group-hover:text-slate-300" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default function Header({ tenant }: { tenant: Tenant | null }) {
|
||||
const { language, toggle, t } = useLanguage();
|
||||
const { user, login, logout } = useAuth();
|
||||
return <header className="console-header"><div className="console-search"><Search size={17}/><input placeholder={t('search')} /></div><div className="console-header-actions"><span className="system-state"><i/>{language === 'ar' ? 'الخدمة متاحة' : 'Service available'}</span><button onClick={toggle}><Languages size={17}/>{t('language')}</button><button onClick={()=>void(user?logout():login())}>{user?<LogOut size={16}/>:<LogIn size={16}/>} {user?(language==='ar'?'خروج':'Sign out'):(language==='ar'?'دخول':'Sign in')}</button><div className="console-user"><span>{tenant?.name || user?.displayName || t('developer')}<small>{tenant?.email || user?.email || (language === 'ar' ? 'سجّل الدخول لإدارة حسابك' : 'Sign in to manage your account')}</small></span><b>{(tenant?.name||user?.displayName)?.slice(0,2).toUpperCase() || 'DV'}</b></div></div></header>;
|
||||
}
|
||||
|
||||
@@ -1,87 +1,9 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Key,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Map as MapIcon,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { BarChart3, BookOpen, CreditCard, FlaskConical, LayoutDashboard, Layers3, PanelLeftClose } from 'lucide-react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { useLanguage } from '../i18n';
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: any;
|
||||
label: string;
|
||||
to: string;
|
||||
export default function Sidebar() {
|
||||
const { t } = useLanguage();
|
||||
const items = [[LayoutDashboard,t('overview'),'/console'],[BarChart3,t('analytics'),'/console/analytics'],[BookOpen,t('docs'),'/console/documentation'],[CreditCard,t('billing'),'/console/billing'],[FlaskConical,t('playground'),'/console/playground']] as const;
|
||||
return <aside className="console-sidebar"><Link to="/" className="brand"><span className="brand-mark"><Layers3 size={20}/></span><span>SIRO<small>{t('brandSubtitle')}</small></span></Link><nav>{items.map(([Icon,label,to])=><NavLink key={to} to={to} end={to==='/console'} className={({isActive})=>isActive?'active':''}><Icon size={18}/><span>{label}</span></NavLink>)}</nav><div className="sidebar-bottom"><Link to="/"><PanelLeftClose size={17}/><span>{t('backWebsite')}</span></Link><small>SIRO Developer Platform<br/>v2.0</small></div></aside>;
|
||||
}
|
||||
|
||||
const SidebarItem = ({ icon: Icon, label, to }: SidebarItemProps) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) => `
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-300
|
||||
${isActive
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<aside className="w-72 h-screen glass border-r border-slate-800 flex flex-col p-6 sticky top-0">
|
||||
<div className="flex items-center gap-3 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-400 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<MapIcon className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight flex items-center gap-2">
|
||||
Intaleq <span className="text-blue-500">Map</span>
|
||||
<span className="text-[8px] bg-blue-500/20 text-blue-500 px-1 rounded">v1.5</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-widest font-black">Developer Portal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2">
|
||||
<SidebarItem icon={LayoutDashboard} label="Dashboard" to="/" />
|
||||
<SidebarItem icon={BarChart3} label="Analytics" to="/analytics" />
|
||||
<SidebarItem icon={Terminal} label="Documentation" to="/documentation" />
|
||||
<SidebarItem icon={CreditCard} label="Billing" to="/billing" />
|
||||
<SidebarItem icon={Key} label="Playground" to="/playground" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-slate-800/50">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5"
|
||||
>
|
||||
<Settings size={18} />
|
||||
Project Settings
|
||||
</NavLink>
|
||||
|
||||
<div className="mt-4 p-4 rounded-xl bg-slate-900/50 border border-slate-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-700"></div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-semibold truncate">Hamza Al-Eghwairyeen</p>
|
||||
<p className="text-xs text-slate-500 truncate">hamza@intaleq.xyz</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="w-full flex items-center justify-center gap-2 text-xs text-rose-500 hover:bg-rose-500/10 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export type Language = 'ar' | 'en';
|
||||
|
||||
type Dictionary = Record<string, { ar: string; en: string }>;
|
||||
|
||||
const dictionary: Dictionary = {
|
||||
brandSubtitle: { ar: 'منصة المطورين', en: 'Developer Platform' },
|
||||
overview: { ar: 'نظرة عامة', en: 'Overview' },
|
||||
analytics: { ar: 'التحليلات', en: 'Analytics' },
|
||||
docs: { ar: 'التوثيق و SDKs', en: 'Docs & SDKs' },
|
||||
billing: { ar: 'الاشتراك والفوترة', en: 'Plans & Billing' },
|
||||
playground: { ar: 'مختبر الخرائط', en: 'Maps Playground' },
|
||||
backWebsite: { ar: 'العودة للموقع', en: 'Back to website' },
|
||||
language: { ar: 'English', en: 'العربية' },
|
||||
console: { ar: 'لوحة المطور', en: 'Developer Console' },
|
||||
search: { ar: 'ابحث في التوثيق أو المفاتيح...', en: 'Search docs or API keys...' },
|
||||
developer: { ar: 'مطور', en: 'Developer' },
|
||||
};
|
||||
|
||||
type LanguageContextValue = {
|
||||
language: Language;
|
||||
dir: 'rtl' | 'ltr';
|
||||
toggle: () => void;
|
||||
t: (key: keyof typeof dictionary) => string;
|
||||
};
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | null>(null);
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>(() =>
|
||||
localStorage.getItem('siro-language') === 'en' ? 'en' : 'ar'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('siro-language', language);
|
||||
document.documentElement.lang = language;
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr';
|
||||
}, [language]);
|
||||
|
||||
const value = useMemo<LanguageContextValue>(() => ({
|
||||
language,
|
||||
dir: language === 'ar' ? 'rtl' : 'ltr',
|
||||
toggle: () => setLanguage(current => current === 'ar' ? 'en' : 'ar'),
|
||||
t: key => dictionary[key][language],
|
||||
}), [language]);
|
||||
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const value = useContext(LanguageContext);
|
||||
if (!value) throw new Error('useLanguage must be used inside LanguageProvider');
|
||||
return value;
|
||||
}
|
||||
+758
-100
File diff suppressed because one or more lines are too long
@@ -1,104 +1,7 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
MousePointer2
|
||||
} from 'lucide-react';
|
||||
import { Activity, BarChart3, Clock, Database, Info } from 'lucide-react';
|
||||
import { useLanguage } from '../i18n';
|
||||
|
||||
const mockData = [
|
||||
{ name: 'Mon', requests: 4000, latency: 240, errors: 2 },
|
||||
{ name: 'Tue', requests: 3000, latency: 198, errors: 1 },
|
||||
{ name: 'Wed', requests: 2000, latency: 310, errors: 4 },
|
||||
{ name: 'Thu', requests: 2780, latency: 208, errors: 1 },
|
||||
{ name: 'Fri', requests: 1890, latency: 250, errors: 2 },
|
||||
{ name: 'Sat', requests: 2390, latency: 210, errors: 1 },
|
||||
{ name: 'Sun', requests: 3490, latency: 225, errors: 0 },
|
||||
];
|
||||
|
||||
const Analytics = () => {
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl text-gradient mb-2">Analytics</h1>
|
||||
<p className="text-slate-400">Deep insights into your API performance and usage patterns</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{[
|
||||
{ label: 'Avg Latency', value: '242ms', icon: Clock, color: 'text-blue-400' },
|
||||
{ label: 'Error Rate', value: '0.04%', icon: AlertCircle, color: 'text-rose-400' },
|
||||
{ label: 'P99 Latency', value: '410ms', icon: Activity, color: 'text-emerald-400' },
|
||||
{ label: 'API Hits', value: '1.2M', icon: MousePointer2, color: 'text-violet-400' }
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="glass p-6 rounded-2xl">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className={`w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center ${stat.color}`}>
|
||||
<stat.icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 font-medium">{stat.label}</p>
|
||||
<div className="text-2xl font-bold tracking-tight">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
<div className="glass p-8 rounded-3xl">
|
||||
<h3 className="text-xl font-bold mb-8">Request Volume</h3>
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={mockData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||||
<XAxis dataKey="name" stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '12px' }}
|
||||
itemStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="requests" stroke="#3b82f6" fillOpacity={1} fill="url(#colorRequests)" strokeWidth={3} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass p-8 rounded-3xl">
|
||||
<h3 className="text-xl font-bold mb-8">Service Latency (ms)</h3>
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={mockData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||||
<XAxis dataKey="name" stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '12px' }}
|
||||
/>
|
||||
<Bar dataKey="latency" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
export default function Analytics() {
|
||||
const { language }=useLanguage(); const ar=language==='ar';
|
||||
return <div className="console-page"><div className="console-page-title"><div><span className="section-kicker">USAGE & HEALTH</span><h1>{ar?'التحليلات':'Analytics'}</h1><p>{ar?'راقب استخدام الواجهات وأداء المشروع بعد اتصال بيانات الاستخدام.':'Monitor API usage and project health once usage data is connected.'}</p></div></div><div className="console-stats">{[[Activity,ar?'طلبات API':'API requests'],[Clock,ar?'زمن الاستجابة':'Response time'],[Database,ar?'نقل البيانات':'Data transfer']].map(([Icon,label])=>{const I=Icon as typeof Activity;return <article key={String(label)}><I/><span><small>{String(label)}</small><b>—</b></span></article>})}</div><section className="console-panel analytics-empty"><BarChart3/><h2>{ar?'بانتظار بيانات الاستخدام الفعلية':'Waiting for real usage data'}</h2><p>{ar?'لن نعرض أرقامًا تجريبية على أنها أداء حقيقي. عند ربط endpoint التحليلات، ستظهر هنا الطلبات والأخطاء وزمن الاستجابة حسب المشروع والمفتاح.':'Demo numbers are not shown as real performance. Once the usage endpoint is connected, requests, errors and latency will appear here by project and key.'}</p><div><Info/><span>{ar?'مصدر البيانات المطلوب: Usage API المرتبط بالمستأجر الحالي.':'Required source: the current tenant Usage API.'}</span></div></section></div>;
|
||||
}
|
||||
|
||||
@@ -1,106 +1,731 @@
|
||||
import {
|
||||
CreditCard,
|
||||
Check
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Check,
|
||||
CircleDollarSign,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
TrendingDown,
|
||||
Server,
|
||||
ArrowUpRight,
|
||||
TrendingUp,
|
||||
CreditCard,
|
||||
Navigation
|
||||
} from 'lucide-react';
|
||||
import { useLanguage } from '../i18n';
|
||||
|
||||
const plans = [
|
||||
type Subscription = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
currentPeriodEnd?: string;
|
||||
monthlyRequestLimit?: number;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
monthlyUsage: number;
|
||||
monthlyLimit: number;
|
||||
totalToday: number;
|
||||
avgLatency: number;
|
||||
successRate: number;
|
||||
};
|
||||
|
||||
const SIRO_INFRA_TIERS = [
|
||||
{
|
||||
name: 'Developer',
|
||||
price: '$0',
|
||||
description: 'Perfect for prototyping and small projects.',
|
||||
features: ['50,000 monthly requests', 'Community support', 'Basic analytics', '1 API Key'],
|
||||
current: true
|
||||
id: 'STARTER',
|
||||
price: '$350',
|
||||
numericPrice: 350,
|
||||
dailyRides: '10,000',
|
||||
maxRides: 10000,
|
||||
nameAr: 'باقة الانطلاق (Starter)',
|
||||
nameEn: 'Sovereign Starter',
|
||||
descAr: 'للشركات الناشئة والتطبيقات المحلية المتنامية.',
|
||||
descEn: 'For growing mobility apps and early production deployments.',
|
||||
qpsAr: 'حتى 2,100 طلب/دقيقة',
|
||||
qpsEn: 'Up to 2,100 req/min',
|
||||
sla: '99.9%',
|
||||
featuresAr: [
|
||||
'سعة حتى 10,000 رحلة يومياً',
|
||||
'اتفاقية مستوى خدمة 99.9% SLA',
|
||||
'زمن استجابة أقل من 10ms',
|
||||
'سحابة سيادية مع عزل بيانات 100%',
|
||||
'دعم فني وتحديثات مستمرة'
|
||||
],
|
||||
featuresEn: [
|
||||
'Up to 10,000 rides / day capacity',
|
||||
'99.9% Enterprise SLA',
|
||||
'Sub-10ms edge latency',
|
||||
'100% Sovereign Data Isolation',
|
||||
'Standard technical support'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Startup',
|
||||
price: '$49',
|
||||
description: 'For growing apps with higher traffic needs.',
|
||||
features: ['500,000 monthly requests', 'Priority support', 'Advanced analytics', '10 API Keys', 'SLA Guarantee'],
|
||||
current: false,
|
||||
popular: true
|
||||
id: 'FLEET',
|
||||
price: '$750',
|
||||
numericPrice: 750,
|
||||
dailyRides: '30,000',
|
||||
maxRides: 30000,
|
||||
nameAr: 'باقة الأساطيل (Fleet)',
|
||||
nameEn: 'Advanced Fleet',
|
||||
descAr: 'لأساطيل التوصيل والنقل الذكي متوسطة الحجم.',
|
||||
descEn: 'For mid-size fleets and delivery networks demanding high resilience.',
|
||||
qpsAr: 'حتى 5,400 طلب/دقيقة',
|
||||
qpsEn: 'Up to 5,400 req/min',
|
||||
sla: '99.95%',
|
||||
popular: true,
|
||||
featuresAr: [
|
||||
'سعة حتى 30,000 رحلة يومياً',
|
||||
'عنقود سيادي ثنائي متزامن ضد الانقطاع',
|
||||
'توجيه ذكي متعدد الوسائط وأساطيل',
|
||||
'اتفاقية مستوى خدمة 99.95% SLA',
|
||||
'دعم متواصل 24/7 عبر القنوات المباشرة'
|
||||
],
|
||||
featuresEn: [
|
||||
'Up to 30,000 rides / day capacity',
|
||||
'Dual-zone redundant sovereign mesh',
|
||||
'Multi-modal fleet routing engine',
|
||||
'99.95% High-availability SLA',
|
||||
'24/7 continuous enterprise support'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Business',
|
||||
price: '$249',
|
||||
description: 'Enterprise-grade performance and security.',
|
||||
features: ['5 Million monthly requests', '24/7 Dedicated support', 'Real-time telemetry', 'Unlimited API Keys', 'Custom map styles'],
|
||||
current: false
|
||||
id: 'SCALE',
|
||||
price: '$1,400',
|
||||
numericPrice: 1400,
|
||||
dailyRides: '75,000',
|
||||
maxRides: 75000,
|
||||
nameAr: 'التوسع الإقليمي (Scale)',
|
||||
nameEn: 'Regional Scale',
|
||||
descAr: 'لمنصات النقل السريع في عدة مدن ودول.',
|
||||
descEn: 'For multi-city ride-hailing and regional logistics networks.',
|
||||
qpsAr: 'حتى 15,000 طلب/دقيقة',
|
||||
qpsEn: 'Up to 15,000 req/min',
|
||||
sla: '99.99%',
|
||||
featuresAr: [
|
||||
'سعة حتى 75,000 رحلة يومياً',
|
||||
'بنية موزعة إقليمياً مع موازنة أحمال ذكية',
|
||||
'خوارزميات مطابقة الطرق (Map Matching)',
|
||||
'تخصيص كامل لمعاملات الملاحة المحلية',
|
||||
'مدير حساب تقني واستشاري مخصص'
|
||||
],
|
||||
featuresEn: [
|
||||
'Up to 75,000 rides / day capacity',
|
||||
'Regionally distributed load-balanced mesh',
|
||||
'Advanced map-matching telemetry',
|
||||
'Customized local routing models',
|
||||
'Dedicated technical account strategist'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ENTERPRISE_FLEET',
|
||||
price: '$2,800',
|
||||
numericPrice: 2800,
|
||||
dailyRides: '200,000',
|
||||
maxRides: 200000,
|
||||
nameAr: 'المؤسسات الكبرى (Enterprise Fleet)',
|
||||
nameEn: 'Enterprise Mobility Fleet',
|
||||
descAr: 'لشركات النقل واللوجستيات الكبرى على مستوى الشرق الأوسط.',
|
||||
descEn: 'For national transport leaders and massive mobility operations.',
|
||||
qpsAr: 'حتى 45,000 طلب/دقيقة',
|
||||
qpsEn: 'Up to 45,000 req/min',
|
||||
sla: '99.995%',
|
||||
featuresAr: [
|
||||
'سعة حتى 200,000 رحلة يومياً',
|
||||
'توسع تلقائي فوري وفق ذروة الطلب اليومية',
|
||||
'نشر سحابي خاص (Private Cloud / VPC)',
|
||||
'تشفير سيادي عسكري للبيانات المكانية',
|
||||
'اتفاقية خدمة 99.995% مع ضمانات تشغيل'
|
||||
],
|
||||
featuresEn: [
|
||||
'Up to 200,000 rides / day capacity',
|
||||
'Dynamic auto-scaling private sovereign VPC',
|
||||
'Private Cloud / On-premise deployment',
|
||||
'Military-grade geospatial data encryption',
|
||||
'99.995% SLA with financial guarantees'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'NATIONAL_GRID',
|
||||
price: '$9,900',
|
||||
numericPrice: 9900,
|
||||
dailyRides: '1,000,000+',
|
||||
maxRides: 1000000,
|
||||
nameAr: 'الشبكة السيادية الوطنية (National Grid)',
|
||||
nameEn: 'National Sovereign Grid',
|
||||
descAr: 'للبنى التحتية الوطنية والمؤسسات الحكومية والأمنية.',
|
||||
descEn: 'For sovereign government infrastructure, defense and national grids.',
|
||||
qpsAr: 'حتى 270,000+ طلب/دقيقة',
|
||||
qpsEn: 'Up to 270,000+ req/min',
|
||||
sla: '99.999%',
|
||||
featuresAr: [
|
||||
'سعة تتجاوز 1,000,000 رحلة يومياً',
|
||||
'نشر داخلي كامل معزول (Air-Gapped Ready)',
|
||||
'سيادة وطنية مطلقة 100% على كافة البيانات',
|
||||
'اتفاقية خدمة استراتيجية 99.999% SLA',
|
||||
'فريق هندسي متخصص مقيم'
|
||||
],
|
||||
featuresEn: [
|
||||
'1,000,000+ rides / day capacity',
|
||||
'Full Air-gapped on-premise deployment',
|
||||
'100% Absolute national data sovereignty',
|
||||
'99.999% Strategic mission-critical SLA',
|
||||
'Dedicated resident engineering team'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const Billing = () => {
|
||||
const PAY_AS_YOU_GO_RATES = [
|
||||
{
|
||||
serviceAr: 'البحث والإكمال (Places Autocomplete)',
|
||||
serviceEn: 'Places Autocomplete',
|
||||
rate: '$0.25',
|
||||
unitAr: 'لكل 1,000 طلب',
|
||||
unitEn: 'per 1k requests',
|
||||
googlePrice: '$2.83',
|
||||
savings: '91%'
|
||||
},
|
||||
{
|
||||
serviceAr: 'تفاصيل الأماكن والترميز الجغرافي (Places Details / Geocode)',
|
||||
serviceEn: 'Places Details / Geocoding',
|
||||
rate: '$0.75',
|
||||
unitAr: 'لكل 1,000 طلب',
|
||||
unitEn: 'per 1k requests',
|
||||
googlePrice: '$17.00',
|
||||
savings: '95%'
|
||||
},
|
||||
{
|
||||
serviceAr: 'الترميز الجغرافي العكسي (Reverse Geocoding)',
|
||||
serviceEn: 'Reverse Geocoding',
|
||||
rate: '$0.25',
|
||||
unitAr: 'لكل 1,000 طلب',
|
||||
unitEn: 'per 1k requests',
|
||||
googlePrice: '$5.00',
|
||||
savings: '95%'
|
||||
},
|
||||
{
|
||||
serviceAr: 'حساب المسارات والملاحة (Directions & Routing)',
|
||||
serviceEn: 'Directions & Routing',
|
||||
rate: '$0.50',
|
||||
unitAr: 'لكل 1,000 طلب',
|
||||
unitEn: 'per 1k requests',
|
||||
googlePrice: '$5.00 - $10.00',
|
||||
savings: '90%'
|
||||
},
|
||||
{
|
||||
serviceAr: 'مصفوفة المسافات (Distance Matrix)',
|
||||
serviceEn: 'Distance Matrix',
|
||||
rate: '$1.00',
|
||||
unitAr: 'لكل 1,000 طلب',
|
||||
unitEn: 'per 1k requests',
|
||||
googlePrice: '$10.00',
|
||||
savings: '90%'
|
||||
},
|
||||
{
|
||||
serviceAr: 'عرض الخرائط المتجهة (Dynamic Vector Maps)',
|
||||
serviceEn: 'Dynamic Vector Maps',
|
||||
rate: '$0.25',
|
||||
unitAr: 'لكل 1,000 جلسة',
|
||||
unitEn: 'per 1k sessions',
|
||||
googlePrice: '$7.00',
|
||||
savings: '96%'
|
||||
}
|
||||
];
|
||||
|
||||
export default function Billing() {
|
||||
const { language } = useLanguage();
|
||||
const ar = language === 'ar';
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'payg' | 'fixed'>('payg');
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
// Interactive Calculator State
|
||||
const [calcRides, setCalcRides] = useState(15000);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
axios.get('/api/billing/subscription').then((r) => r.data).catch(() => null),
|
||||
axios.get('/api/usage/summary').then((r) => r.data).catch(() => null)
|
||||
])
|
||||
.then(([subData, usageData]) => {
|
||||
if (subData) setSubscription(subData);
|
||||
if (usageData) setUsage(usageData);
|
||||
})
|
||||
.catch(() => {
|
||||
setNotice(
|
||||
ar
|
||||
? 'سجّل الدخول لعرض خطتك الحالية وترقيتها.'
|
||||
: 'Sign in to view and upgrade your current plan.'
|
||||
);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ar]);
|
||||
|
||||
const checkout = async (plan: string) => {
|
||||
try {
|
||||
setBusy(plan);
|
||||
const response = await axios.post('/api/billing/checkout', {
|
||||
plan,
|
||||
provider: 'PAYMOB'
|
||||
});
|
||||
if (response.data?.checkoutUrl) {
|
||||
window.location.assign(response.data.checkoutUrl);
|
||||
}
|
||||
} catch {
|
||||
setNotice(
|
||||
ar
|
||||
? 'تعذر بدء الدفع. تحقق من الجلسة وإعدادات مزود الدفع.'
|
||||
: 'Could not start checkout. Check your session and payment provider configuration.'
|
||||
);
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
// Calculator calculations
|
||||
const googleMonthly = Math.round(calcRides * 30 * 0.055);
|
||||
const siroPaygMonthly = Math.round(calcRides * 30 * 0.0035);
|
||||
|
||||
// Determine matching fixed tier for this volume
|
||||
const matchingTier =
|
||||
SIRO_INFRA_TIERS.find((t) => calcRides <= t.maxRides) ||
|
||||
SIRO_INFRA_TIERS[SIRO_INFRA_TIERS.length - 1];
|
||||
const siroFixedMonthly = matchingTier.numericPrice;
|
||||
|
||||
// Best Siro price between PAYG and Fixed Tier
|
||||
const siroBestMonthly = Math.min(siroPaygMonthly, siroFixedMonthly);
|
||||
const monthlySavings = Math.max(0, googleMonthly - siroBestMonthly);
|
||||
const savingsPercent = googleMonthly > 0 ? Math.round((monthlySavings / googleMonthly) * 100) : 93;
|
||||
|
||||
// Monthly requests calculation
|
||||
const freeLimit = 10000;
|
||||
const currentRequests = usage?.monthlyUsage || 0;
|
||||
const freePercent = Math.min(100, Math.round((currentRequests / freeLimit) * 100));
|
||||
|
||||
// Current plan display helper
|
||||
const planId = subscription?.plan || 'FREE';
|
||||
const isFreePlan = planId === 'FREE';
|
||||
const isPaygPlan = planId === 'PAY_AS_YOU_GO';
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl text-gradient mb-2">Billing & Plans</h1>
|
||||
<p className="text-slate-400">Manage your subscription, usage limits, and billing history</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{plans.map((plan, i) => (
|
||||
<div key={i} className={`glass p-8 rounded-3xl flex flex-col relative transition-transform duration-300 hover:scale-[1.02] ${plan.popular ? 'border-blue-500/50' : ''}`}>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-[10px] font-black uppercase tracking-widest px-4 py-1.5 rounded-full shadow-lg shadow-blue-500/20">
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-bold mb-1">{plan.name}</h3>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-black">{plan.price}</span>
|
||||
<span className="text-slate-500 text-sm font-medium">/mo</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4 mb-10 flex-1">
|
||||
{plan.features.map((feature, j) => (
|
||||
<li key={j} className="flex items-center gap-3 text-sm text-slate-300">
|
||||
<div className="w-5 h-5 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||
<Check size={12} className="text-blue-500" />
|
||||
</div>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button className={`w-full py-4 rounded-2xl text-sm font-bold transition-all duration-300 ${plan.current ? 'bg-slate-800 text-slate-400 cursor-default' : 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-500/20'}`}>
|
||||
{plan.current ? 'Current Plan' : 'Upgrade Plan'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="glass p-8 rounded-3xl mb-12 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-500/5 blur-[100px]" />
|
||||
<h3 className="text-xl font-bold mb-6">Current Usage</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-3">
|
||||
<span className="text-sm font-bold text-slate-400 uppercase tracking-widest">Monthly Requests</span>
|
||||
<span className="text-sm font-mono text-blue-400">42,891 / 50,000</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-slate-950 rounded-full border border-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-blue-600 to-cyan-400 rounded-full" style={{ width: '85%' }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-3 font-medium uppercase tracking-widest">Resetting in 14 days</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 p-6 bg-slate-950/50 rounded-2xl border border-slate-800">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-600/10 flex items-center justify-center text-blue-500">
|
||||
<CreditCard size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold">Payment Method</p>
|
||||
<p className="text-xs text-slate-500">Visa ending in 4242</p>
|
||||
</div>
|
||||
<button className="ml-auto text-xs text-blue-500 font-bold hover:underline">Update</button>
|
||||
</div>
|
||||
<div className="console-page">
|
||||
{/* Page Title */}
|
||||
<div className="console-page-title">
|
||||
<div>
|
||||
<span className="section-kicker">PLANS & SOVEREIGN BILLING</span>
|
||||
<h1>{ar ? 'الاشتراك والفوترة والجدوى' : 'Plans, Billing & Unit Economics'}</h1>
|
||||
<p>
|
||||
{ar
|
||||
? 'اختر بين نموذج الدفع حسب الاستهلاك (Pay-As-You-Go) أو باقات البنية التحتية السحابية الثابتة. وفر حتى 95% مقارنة بـ Google Maps.'
|
||||
: 'Choose between transparent Pay-As-You-Go or predictable fixed cloud tiers. Save up to 95% compared to Google Maps.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className="console-alert">
|
||||
<CircleDollarSign />
|
||||
<div>
|
||||
<b>{notice}</b>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription & Free Trial Status Dashboard */}
|
||||
<section className="billing-status-dashboard">
|
||||
{/* Active Plan Card */}
|
||||
<div className="billing-card active-plan-card">
|
||||
<div className="card-header">
|
||||
<ShieldCheck size={22} className="icon-lime" />
|
||||
<div>
|
||||
<small>{ar ? 'الخطة الحالية' : 'CURRENT PLAN'}</small>
|
||||
<h3>
|
||||
{loading
|
||||
? '…'
|
||||
: isFreePlan
|
||||
? ar
|
||||
? 'فترة تجريبية مجانية (Free Sandbox)'
|
||||
: 'Free Sandbox Trial'
|
||||
: isPaygPlan
|
||||
? ar
|
||||
? 'الدفع حسب الاستهلاك (Pay-As-You-Go)'
|
||||
: 'Pay-As-You-Go'
|
||||
: planId}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="plan-summary-note">
|
||||
{isFreePlan
|
||||
? ar
|
||||
? 'لديك 10,000 طلب مجاني شهرياً لتجربة المنصة وتطوير التطبيقات بدون أي رسوم.'
|
||||
: 'Includes 10,000 free requests per month for testing and app development.'
|
||||
: ar
|
||||
? 'استهلاك مرن وغير محدود بنصف أسعار السوق مع حماية كاملة لهوامش الأرباح.'
|
||||
: 'Flexible consumption at 50% below market rates with gross margin protection.'}
|
||||
</p>
|
||||
<div className="plan-meta-row">
|
||||
<span>
|
||||
<small>{ar ? 'حالة الحساب' : 'Status'}:</small>{' '}
|
||||
<b className="text-lime">{subscription?.status || (ar ? 'نشط' : 'Active')}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{ar ? 'نهاية الفترة' : 'Period End'}:</small>{' '}
|
||||
<b>
|
||||
{subscription?.currentPeriodEnd
|
||||
? new Date(subscription.currentPeriodEnd).toLocaleDateString(language)
|
||||
: ar
|
||||
? 'تتجدد تلقائياً'
|
||||
: 'Auto-renews'}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Free Quota / Usage Progress */}
|
||||
<div className="billing-card quota-card">
|
||||
<div className="card-header">
|
||||
<Zap size={22} className="icon-lime" />
|
||||
<div>
|
||||
<small>{ar ? 'الحصة التجريبية الشهرية' : 'MONTHLY TRIAL QUOTA'}</small>
|
||||
<h3>
|
||||
{currentRequests.toLocaleString()} / {freeLimit.toLocaleString()}{' '}
|
||||
<small>{ar ? 'طلب' : 'reqs'}</small>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="quota-bar-wrapper">
|
||||
<div className="quota-bar-fill" style={{ width: `${freePercent}%` }} />
|
||||
</div>
|
||||
<div className="quota-footer">
|
||||
<span>
|
||||
{ar
|
||||
? `المتبقي: ${(Math.max(0, freeLimit - currentRequests)).toLocaleString()} طلب مجاني`
|
||||
: `${(Math.max(0, freeLimit - currentRequests)).toLocaleString()} free reqs left`}
|
||||
</span>
|
||||
<span>{freePercent}% {ar ? 'مستخدم' : 'used'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-Time Estimated Savings */}
|
||||
<div className="billing-card savings-card">
|
||||
<div className="card-header">
|
||||
<TrendingDown size={22} className="icon-lime" />
|
||||
<div>
|
||||
<small>{ar ? 'الوفر المالي مقابل Google Maps' : 'SAVINGS VS GOOGLE MAPS'}</small>
|
||||
<h3 className="text-lime">
|
||||
~${(Math.round(currentRequests * 0.0515)).toLocaleString()}{' '}
|
||||
<span className="badge-pill">وفر 90%+</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="savings-detail">
|
||||
{ar
|
||||
? 'توفر سيرو أكثر من 0.30$ في كل رحلة مكتملة مقارنة بتكلفة Google Maps التراكمية.'
|
||||
: 'Siro saves $0.30+ per completed ride compared to legacy Google Maps billing spikes.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Models Tabs */}
|
||||
<div className="pricing-model-tabs-box">
|
||||
<div className="model-tabs">
|
||||
<button
|
||||
className={`model-tab-btn ${activeTab === 'payg' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('payg')}
|
||||
>
|
||||
<Zap size={18} />
|
||||
<span>{ar ? '1. الدفع حسب الاستهلاك (Pay-As-You-Go)' : '1. Pay-As-You-Go (50% Off)'}</span>
|
||||
<small>{ar ? '10,000 طلب مجاني شهرياً' : '10k free requests / mo'}</small>
|
||||
</button>
|
||||
<button
|
||||
className={`model-tab-btn ${activeTab === 'fixed' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('fixed')}
|
||||
>
|
||||
<Server size={18} />
|
||||
<span>{ar ? '2. باقات البنية التحتية السحابية الثابتة' : '2. Fixed Cloud Infrastructure'}</span>
|
||||
<small>{ar ? 'فاتورة شهرية ثابتة بلا مفاجآت' : 'Predictable flat monthly fee'}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: PAY AS YOU GO */}
|
||||
{activeTab === 'payg' && (
|
||||
<section className="payg-section">
|
||||
<div className="section-headline">
|
||||
<div>
|
||||
<span className="section-kicker">01 / PAY-AS-YOU-GO RATES</span>
|
||||
<h2>{ar ? 'تسعير واجهات البرمجة حسب الاستهلاك الفعلي' : 'Transparent Per-1,000 Requests Pricing'}</h2>
|
||||
<p>
|
||||
{ar
|
||||
? 'احصل على 10,000 طلب مجاني شهرياً، وادفع فقط نصف أسعار أقل منافس في السوق دون أي عقود ملزمة أو رسوم خفية.'
|
||||
: 'Get 10,000 free requests every month. Pay 50% below the lowest market competitor with no commitments.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button lime"
|
||||
onClick={() => void checkout('PAY_AS_YOU_GO')}
|
||||
disabled={busy !== '' || isPaygPlan}
|
||||
>
|
||||
{busy === 'PAY_AS_YOU_GO' ? (
|
||||
<Loader2 className="spin" size={16} />
|
||||
) : isPaygPlan ? (
|
||||
ar ? 'خطتك المفعلة حالياً' : 'Active Plan'
|
||||
) : (
|
||||
<>
|
||||
<CreditCard size={16} />
|
||||
{ar ? 'تفعيل الدفع حسب الاستهلاك' : 'Activate Pay-As-You-Go'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-container">
|
||||
<table className="pricing-comp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{ar ? 'واجهة البرمجة (API Service)' : 'API Service'}</th>
|
||||
<th>{ar ? 'سعر سيرو (Siro Rate)' : 'Siro Rate'}</th>
|
||||
<th>{ar ? 'وحدة الاستهلاك' : 'Billing Unit'}</th>
|
||||
<th>Google Maps</th>
|
||||
<th>{ar ? 'نسبة التوفير المباشر' : 'Direct Savings'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PAY_AS_YOU_GO_RATES.map((item) => (
|
||||
<tr key={item.serviceEn}>
|
||||
<td>
|
||||
<strong>{ar ? item.serviceAr : item.serviceEn}</strong>
|
||||
</td>
|
||||
<td className="siro-price siro-col">
|
||||
<strong style={{ fontSize: '15px' }}>{item.rate}</strong>
|
||||
</td>
|
||||
<td>{ar ? item.unitAr : item.unitEn}</td>
|
||||
<td className="google-price">{item.googlePrice}</td>
|
||||
<td>
|
||||
<span className="savings-pill">{item.savings}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="summary-highlight-row">
|
||||
<td>
|
||||
<strong>{ar ? 'تكلفة الـ 7 طلبات للرحلة المكتملة' : '7 Core API Calls / Single Trip'}</strong>
|
||||
<br />
|
||||
<small style={{ color: 'var(--muted)', fontSize: '11px' }}>
|
||||
{ar ? '(2 إكمال + 1 تفاصيل + 1 ترميز عكسي + 1 مصفوفة + 1 مسار + 1 خريطة)' : '(2 Autocomplete + 1 Details + 1 Rev-Geo + 1 Matrix + 1 Route + 1 Map)'}
|
||||
</small>
|
||||
</td>
|
||||
<td className="siro-price siro-col">
|
||||
<strong style={{ fontSize: '17px' }}>~$0.0035</strong>
|
||||
</td>
|
||||
<td>{ar ? 'لكل رحلة مكتملة' : 'per completed ride'}</td>
|
||||
<td className="google-price">~$0.055 - $0.28</td>
|
||||
<td>
|
||||
<span className="savings-pill">وفر 93%+</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TAB 2: FIXED SOVEREIGN TIERS */}
|
||||
{activeTab === 'fixed' && (
|
||||
<section className="fixed-tiers-section">
|
||||
<div className="section-headline">
|
||||
<div>
|
||||
<span className="section-kicker">02 / FIXED SOVEREIGN INFRASTRUCTURE</span>
|
||||
<h2>{ar ? 'باقات البنية التحتية السحابية الثابتة للأساطيل' : 'Predictable Fixed Monthly Cloud Infrastructure'}</h2>
|
||||
<p>
|
||||
{ar
|
||||
? 'اشتراك شهري مقطوع يزيل مفاجآت فواتير الاستهلاك لكل طلب ويمنحك سعة خوادم مخصصة واتفاقية مستوى خدمة SLA حتى 99.999%.'
|
||||
: 'Predictable flat monthly cloud pricing eliminating per-call billing spikes, backed by enterprise SLA up to 99.999%.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed-plans-grid">
|
||||
{SIRO_INFRA_TIERS.map((tier) => {
|
||||
const isCurrent = subscription?.plan === tier.id;
|
||||
return (
|
||||
<article key={tier.id} className={`fixed-tier-card ${tier.popular ? 'popular' : ''}`}>
|
||||
{tier.popular && (
|
||||
<span className="plan-badge">{ar ? 'الأكثر طلباً للأساطيل' : 'MOST POPULAR'}</span>
|
||||
)}
|
||||
<div className="tier-header">
|
||||
<small>{tier.id}</small>
|
||||
<h3>{ar ? tier.nameAr : tier.nameEn}</h3>
|
||||
<div className="tier-price-box">
|
||||
<strong>{tier.price}</strong>
|
||||
<em>/ {ar ? 'شهر' : 'month'}</em>
|
||||
</div>
|
||||
<p>{ar ? tier.descAr : tier.descEn}</p>
|
||||
</div>
|
||||
|
||||
<div className="tier-capacity-pill">
|
||||
<Navigation size={14} />
|
||||
<span>{ar ? `سعة حتى ${tier.dailyRides} رحلة/يوم` : `Up to ${tier.dailyRides} rides/day`}</span>
|
||||
</div>
|
||||
|
||||
<ul className="tier-features-list">
|
||||
{(ar ? tier.featuresAr : tier.featuresEn).map((f) => (
|
||||
<li key={f}>
|
||||
<Check size={14} />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="tier-action-box">
|
||||
{tier.id === 'NATIONAL_GRID' ? (
|
||||
<a className="button ghost" href="mailto:sales@intaleqapp.com">
|
||||
{ar ? 'تواصل مع المبيعات' : 'Contact Sales'}
|
||||
<ArrowUpRight size={14} />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className={`button ${tier.popular ? 'lime' : 'ghost'}`}
|
||||
onClick={() => void checkout(tier.id)}
|
||||
disabled={busy !== '' || isCurrent}
|
||||
>
|
||||
{busy === tier.id ? (
|
||||
<Loader2 className="spin" size={15} />
|
||||
) : isCurrent ? (
|
||||
ar ? 'خطتك الحالية' : 'Current Plan'
|
||||
) : (
|
||||
<>
|
||||
{ar ? 'ترقية الباقة' : 'Upgrade Plan'}
|
||||
<ArrowUpRight size={14} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Interactive Developer ROI & Invoice Calculator */}
|
||||
<section className="developer-calc-section">
|
||||
<div className="calc-header">
|
||||
<div>
|
||||
<span className="section-kicker">INTERACTIVE INVOICE CALCULATOR</span>
|
||||
<h2>{ar ? 'احسب فاتورتك الشهرية والوفر المحقق بدقة' : 'Calculate Your Monthly Bill & Direct Savings'}</h2>
|
||||
<p>
|
||||
{ar
|
||||
? 'حرّك المؤشر وفق عدد الرحلات اليومية لأسطولك لمقارنة الفاتورة التقديرية بين Google Maps وسيرو.'
|
||||
: 'Slide to your fleet’s daily completed rides to instantly compare monthly billing between Google Maps and Siro.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="calc-grid">
|
||||
{/* Slider Panel */}
|
||||
<div className="calc-control-panel">
|
||||
<div className="slider-label-row">
|
||||
<span>
|
||||
<Navigation size={18} /> {ar ? 'عدد الرحلات اليومية لأسطولك:' : 'Daily Completed Rides:'}
|
||||
</span>
|
||||
<strong className="calc-value-pill">
|
||||
{calcRides.toLocaleString()} {ar ? 'رحلة / يوم' : 'rides / day'}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1000"
|
||||
max="500000"
|
||||
step="1000"
|
||||
value={calcRides}
|
||||
onChange={(e) => setCalcRides(Number(e.target.value))}
|
||||
className="roi-slider"
|
||||
/>
|
||||
|
||||
<div className="preset-buttons">
|
||||
{[5000, 15000, 30000, 75000, 150000, 300000].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
className={`preset-btn ${calcRides === preset ? 'active' : ''}`}
|
||||
onClick={() => setCalcRides(preset)}
|
||||
>
|
||||
{preset >= 1000000 ? `${preset / 1000000}M` : `${preset / 1000}K`}{' '}
|
||||
{ar ? 'رحلة' : 'rides'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calc-spec-note">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<b>{ar ? 'حماية هوامش الأرباح (Gross Margin Protection)' : 'Gross Margin Protection'}</b>
|
||||
<p>
|
||||
{ar
|
||||
? `توفير مباشر بمقدار ~$${((calcRides * 30 * (0.055 - 0.0035))).toLocaleString()} شهرياً ينعكس مباشرة كأرباح صافية لمشروعك.`
|
||||
: `Direct savings of ~$${((calcRides * 30 * (0.055 - 0.0035))).toLocaleString()} monthly directly protects your gross margin.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Comparison Grid */}
|
||||
<div className="calc-results-panel">
|
||||
<div className="result-stat-box google">
|
||||
<span className="stat-kicker">GOOGLE MAPS</span>
|
||||
<h3>${googleMonthly.toLocaleString()}</h3>
|
||||
<small>{ar ? 'الفاتورة الشهرية المقدرة' : 'Estimated Monthly Bill'}</small>
|
||||
<div className="mini-bar-bg">
|
||||
<div className="mini-bar-fill google-bar" style={{ width: '100%' }} />
|
||||
</div>
|
||||
<p>{ar ? 'بناءً على $0.055 لكل رحلة' : 'Based on $0.055 / trip'}</p>
|
||||
</div>
|
||||
|
||||
<div className="result-stat-box siro">
|
||||
<span className="stat-kicker">SIRO MAPS</span>
|
||||
<h3 className="text-lime">${siroBestMonthly.toLocaleString()}</h3>
|
||||
<small>
|
||||
{ar
|
||||
? `أفضل خيار: ${matchingTier.nameAr} (${siroBestMonthly === siroFixedMonthly ? 'باقة ثابتة' : 'استهلاك مرن'})`
|
||||
: `Best Option: ${matchingTier.nameEn}`}
|
||||
</small>
|
||||
<div className="mini-bar-bg">
|
||||
<div
|
||||
className="mini-bar-fill siro-bar"
|
||||
style={{ width: `${Math.max(4, (siroBestMonthly / googleMonthly) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{ar
|
||||
? `تكلفة الرحلة: $0.0035 فقط (وفر 93%)`
|
||||
: `Unit cost: $0.0035 / ride (93% savings)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="result-stat-box savings">
|
||||
<div className="savings-tag">
|
||||
<TrendingUp size={14} /> {ar ? `وفر ${savingsPercent}% شهرياً` : `${savingsPercent}% Net Savings`}
|
||||
</div>
|
||||
<span className="stat-kicker">{ar ? 'صافي التوفير الشهري' : 'MONTHLY NET SAVINGS'}</span>
|
||||
<h2 className="savings-value">${monthlySavings.toLocaleString()}</h2>
|
||||
<div className="annual-note">
|
||||
<span>{ar ? 'الوفر السنوي الإجمالي' : 'Annual Savings'}:</span>
|
||||
<strong>${(monthlySavings * 12).toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="billing-footnote">
|
||||
{ar
|
||||
? 'جميع الأسعار محسوبة بالدولار الأمريكي وتخضع لاتفاقيات مستوى الخدمة الرسمية. للدفع بالعملات المحلية أو عبر الحوالات البنكية المباشرة، يرجى التواصل مع فريق المبيعات.'
|
||||
: 'All prices in USD. For enterprise invoicing, local currency or bank transfer settlements, contact sales@intaleqapp.com.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
}
|
||||
|
||||
@@ -1,300 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Plus,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ShieldCheck,
|
||||
Globe,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { Activity, ArrowUpRight, CheckCircle2, Copy, CreditCard, Eye, EyeOff, FlaskConical, KeyRound, Loader2, Plus, RefreshCw, ShieldCheck, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ApiKey, Tenant } from '../App';
|
||||
import { useLanguage } from '../i18n';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
rateLimit: number;
|
||||
allowedOrigins: string[];
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
type Props = { tenant: Tenant | null; keys: ApiKey[]; loading: boolean; connectionError: boolean; onRefresh: () => void; };
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface DashboardHomeProps {
|
||||
tenant: Tenant | null;
|
||||
keys: ApiKey[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps) => {
|
||||
export default function DashboardHome({ tenant, keys, loading, connectionError, onRefresh }: Props) {
|
||||
const { language } = useLanguage();
|
||||
const ar = language === 'ar';
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyLimit, setNewKeyLimit] = useState(100);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const toggleKeyVisibility = (id: string) => {
|
||||
setShowKeys(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
const createKey = async () => {
|
||||
if (!name.trim()) return setMessage(ar ? 'أدخل اسمًا للمفتاح.' : 'Enter a key name.');
|
||||
try { setBusy(true); await axios.post('/api/auth/management/keys',{name,rateLimit:limit}); setModal(false); setName(''); setMessage(''); onRefresh(); }
|
||||
catch { setMessage(ar ? 'تعذر إنشاء المفتاح. تحقق من تسجيل الدخول.' : 'Could not create the key. Check your session.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const revoke = async (key: ApiKey) => {
|
||||
if (!window.confirm(ar ? `إلغاء المفتاح “${key.name}”؟` : `Revoke “${key.name}”?`)) return;
|
||||
await axios.delete(`/api/auth/management/keys/${key.id}`); onRefresh();
|
||||
};
|
||||
|
||||
const createKey = async (name: string, limit: number) => {
|
||||
if (!tenant) return;
|
||||
try {
|
||||
await axios.post(`/api/auth/management/keys`, {
|
||||
name,
|
||||
rateLimit: limit
|
||||
});
|
||||
onRefresh();
|
||||
setIsModalOpen(false);
|
||||
setNewKeyName('');
|
||||
} catch (e) {
|
||||
alert('Failed to create key');
|
||||
}
|
||||
};
|
||||
return <div className="console-page">
|
||||
<div className="console-page-title"><div><span className="section-kicker">DEVELOPER CONSOLE</span><h1>{ar ? `مرحبًا${tenant?.name ? `، ${tenant.name}` : ''}` : `Welcome${tenant?.name ? `, ${tenant.name}` : ''}`}</h1><p>{ar ? 'المفاتيح، التجربة، التوثيق والاشتراك في مكان واحد.' : 'Keys, testing, documentation and plans in one place.'}</p></div><Link className="button lime" to="/console/playground">{ar ? 'افتح المختبر' : 'Open playground'}<ArrowUpRight size={16}/></Link></div>
|
||||
|
||||
const deleteKey = async (keyId: string, keyName: string) => {
|
||||
if (!window.confirm(`Are you sure you want to revoke the API key "${keyName || 'Production Key'}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/auth/management/keys/${keyId}`);
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
alert('Failed to revoke key');
|
||||
}
|
||||
};
|
||||
{connectionError && <div className="console-alert"><TriangleAlert size={19}/><div><b>{ar ? 'لم يتم العثور على جلسة مطور نشطة' : 'No active developer session found'}</b><p>{ar ? 'الواجهة جاهزة، لكن بيانات الحساب تحتاج إلى تسجيل الدخول وربط Firebase الحالي بالنسخة الموحدة.' : 'The interface is ready, but account data requires sign-in through the existing Firebase connection.'}</p></div><button onClick={onRefresh}><RefreshCw size={15}/>{ar ? 'إعادة المحاولة' : 'Retry'}</button></div>}
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl text-gradient mb-2">
|
||||
{loading ? 'Welcome back...' : `Welcome back, ${tenant?.name || 'Developer'}`}
|
||||
</h1>
|
||||
<p className="text-slate-400">Everything you need to build with premium Jordan Map Platform API</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{[
|
||||
{ label: 'Total Requests', value: '42,891', diff: '+12.5%', icon: Zap, color: 'text-blue-400' },
|
||||
{ label: 'Success Rate', value: '99.98%', diff: '+0.01%', icon: ShieldCheck, color: 'text-emerald-400' },
|
||||
{ label: 'Active Keys', value: loading ? '...' : keys.length.toString(), diff: 'Stable', icon: Activity, color: 'text-cyan-400' },
|
||||
{ label: 'Map Load Speed', value: '342ms', diff: '-12ms', icon: TrendingUp, color: 'text-violet-400' }
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="glass p-6 rounded-2xl group transition-all duration-300 hover:border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center ${stat.color} group-hover:scale-110 transition-transform`}>
|
||||
<stat.icon size={20} />
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded-full bg-slate-900 ${stat.diff.startsWith('+') ? 'text-emerald-500' : 'text-slate-500'}`}>
|
||||
{stat.diff}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 font-medium mb-1">{stat.label}</p>
|
||||
<div className="text-2xl font-bold tracking-tight">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center px-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setIsModalOpen(false)}></div>
|
||||
<div className="glass w-full max-w-md p-8 rounded-3xl relative animate-in fade-in zoom-in duration-300">
|
||||
<h2 className="text-2xl font-bold mb-2">Create API Key</h2>
|
||||
<p className="text-sm text-slate-500 mb-8">Set up a new access point for your application.</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-black uppercase tracking-widest text-slate-500 mb-2">Key Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Production Web App"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
value={newKeyName}
|
||||
onChange={e => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black uppercase tracking-widest text-slate-500 mb-2">Rate Limit (Req/Min)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
value={newKeyLimit}
|
||||
onChange={e => setNewKeyLimit(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button className="flex-1 btn btn-secondary" onClick={() => setIsModalOpen(false)}>Cancel</button>
|
||||
<button className="flex-1 btn btn-primary" onClick={() => createKey(newKeyName, newKeyLimit)}>Create Key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
{/* Chart Area */}
|
||||
<div className="lg:col-span-2 glass rounded-2xl p-6 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">Request Traffic</h3>
|
||||
<p className="text-sm text-slate-500">Live traffic across all API endpoints</p>
|
||||
</div>
|
||||
<Link to="/analytics" className="text-xs text-blue-500 hover:text-blue-400 font-bold flex items-center gap-1 transition-colors">
|
||||
Full Analytics <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="h-64 flex items-end gap-2 px-2 relative">
|
||||
{[40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-gradient-to-t from-blue-600/20 to-blue-400/80 rounded-t-sm relative group" style={{ height: `${h}%` }}>
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 glass px-2 py-1 rounded text-[10px] font-bold opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
{h}k
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none pr-4">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="w-full border-t border-slate-800/50 h-0"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="glass rounded-2xl p-6 bg-gradient-to-br from-blue-600/10 to-transparent border-blue-500/10">
|
||||
<h3 className="text-lg font-bold mb-2">Quick Start</h3>
|
||||
<p className="text-sm text-slate-500 mb-6 font-medium">Get started with our lightweight React SDK in seconds.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs">
|
||||
<p className="text-slate-500 mb-2"># Install with npm</p>
|
||||
<p className="text-blue-400">npm <span className="text-slate-200">install @intaleq/maps-gl</span></p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full btn btn-secondary text-sm group"
|
||||
onClick={() => window.open('http://188.68.36.205:3200/api/docs', '_blank')}
|
||||
>
|
||||
<Terminal size={16} className="text-blue-400 group-hover:scale-110 transition-transform" />
|
||||
View API Reference
|
||||
</button>
|
||||
<Link to="/playground" className="w-full btn btn-secondary text-sm group">
|
||||
<Globe size={16} className="text-cyan-400 group-hover:scale-110 transition-transform" />
|
||||
Try Maps Playground
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys Table */}
|
||||
<div className="glass rounded-2xl overflow-hidden mb-12">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-white/[0.02]">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">Your API Keys</h3>
|
||||
<p className="text-sm text-slate-500">Manage keys for your web and mobile applications</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={loading || keys.length >= 1}
|
||||
title={keys.length >= 1 ? "Limit of 1 API key per developer reached" : ""}
|
||||
>
|
||||
<Plus size={18} />
|
||||
{keys.length >= 1 ? "Key Limit Reached" : "Create New Key"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center p-20 gap-4">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
<p className="text-slate-500 animate-pulse">Fetching your secure keys...</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-widest text-slate-500 bg-slate-900/40">
|
||||
<th className="px-6 py-4 font-black">Name</th>
|
||||
<th className="px-6 py-4 font-black">API Key</th>
|
||||
<th className="px-6 py-4 font-black">Status</th>
|
||||
<th className="px-6 py-4 font-black">Restrictions</th>
|
||||
<th className="px-6 py-4 font-black text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{keys.map((apiKey) => (
|
||||
<tr key={apiKey.id} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-6 py-6 font-medium">{apiKey.name}</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex items-center gap-2 bg-slate-900 rounded-lg px-3 py-1.5 w-fit border border-slate-800">
|
||||
<code className="text-xs text-blue-400 font-mono">
|
||||
{showKeys[apiKey.id] ? apiKey.key : "in_••••••••••••••••••••••••"}
|
||||
</code>
|
||||
<div className="flex items-center gap-1 ml-2 border-l border-slate-800 pl-2">
|
||||
<button onClick={() => toggleKeyVisibility(apiKey.id)} className="text-slate-500 hover:text-white">
|
||||
{showKeys[apiKey.id] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button className="text-slate-500 hover:text-white" onClick={() => navigator.clipboard.writeText(apiKey.key)}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<span className={`flex items-center gap-1.5 text-xs font-bold ${apiKey.isActive ? 'text-emerald-400' : 'text-slate-500'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${apiKey.isActive ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||
{apiKey.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-6">
|
||||
<div className="flex gap-2">
|
||||
{Array.isArray(apiKey?.allowedOrigins) && apiKey.allowedOrigins.length > 0 ? (
|
||||
apiKey.allowedOrigins.map((org: string, idx: number) => (
|
||||
<span key={idx} className="px-2 py-0.5 rounded-md bg-blue-500/10 text-[10px] uppercase font-black text-blue-400 flex items-center gap-1">
|
||||
{Globe ? <Globe size={10} /> : '•'} {org}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded-md bg-slate-800 text-[10px] uppercase font-black text-slate-400 flex items-center gap-1">
|
||||
{Globe ? <Globe size={10} /> : '•'} Universal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-6 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button className="p-2 text-slate-500 hover:text-white" title="Refresh" onClick={onRefresh}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-500 hover:text-red-400 transition-colors" title="Revoke Key" onClick={() => deleteKey(apiKey.id, apiKey.name)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-stats">
|
||||
<article><KeyRound/><span><small>{ar?'المفاتيح النشطة':'Active keys'}</small><b>{loading?'—':keys.filter(k=>k.isActive).length}</b></span></article>
|
||||
<article><ShieldCheck/><span><small>{ar?'حماية النطاقات':'Origin restrictions'}</small><b>{keys.some(k=>k.allowedOrigins?.length)?(ar?'مفعّلة':'Enabled'):(ar?'غير مضبوطة':'Not set')}</b></span></article>
|
||||
<article><Activity/><span><small>{ar?'آخر استخدام':'Last activity'}</small><b>{keys.find(k=>k.lastUsedAt)?.lastUsedAt ? new Date(keys.find(k=>k.lastUsedAt)!.lastUsedAt!).toLocaleDateString(language) : (ar?'لا يوجد':'No data')}</b></span></article>
|
||||
<article style={{ borderLeft: '2px solid var(--lime)' }}><CreditCard style={{ color: 'var(--lime)' }}/><span><small>{ar?'الخطة والفوترة':'Plan & Quota'}</small><b>{ar?'10,000 مجاني / Pay-As-You-Go':'10k Free / PAYG'}</b><Link to="/console/billing" style={{ fontSize: '10px', color: 'var(--lime)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>{ar?'إدارة الاشتراك والوفر':'Manage & Savings'}<ArrowUpRight size={12}/></Link></span></article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHome;
|
||||
<div className="console-grid"><section className="console-panel quickstart"><div className="panel-heading"><div><span>01</span><h2>{ar?'ابدأ التكامل':'Start integrating'}</h2></div><Link to="/console/documentation">{ar?'كل التوثيق':'All docs'}<ArrowUpRight size={14}/></Link></div><pre dir="ltr"><code><i>npm</i> install intaleq-maps-gl</code></pre><div className="onboarding-steps">{[[CheckCircle2,ar?'اختر حزمة التطوير':'Choose an SDK'],[KeyRound,ar?'أنشئ مفتاح API':'Create an API key'],[FlaskConical,ar?'اختبر طلبًا في المختبر':'Test a request']].map(([Icon,text],i)=>{const I=Icon as typeof CheckCircle2;return <Link to={i===0?'/console/documentation':i===1?'/console':'/console/playground'} key={String(text)}><span>{i+1}</span><I size={18}/><b>{String(text)}</b><ArrowUpRight size={14}/></Link>})}</div></section><section className="console-panel sdk-summary"><div className="panel-heading"><div><span>02</span><h2>{ar?'حزم التطوير':'SDK coverage'}</h2></div></div><div className="sdk-mini-grid">{[['JS','JavaScript / TypeScript'],['FL','Flutter'],['SW','iOS / Swift'],['KT','Android / Kotlin']].map(([mark,label])=><Link to="/console/documentation" key={mark}><span>{mark}</span><b>{label}</b><small>{ar?'دليل البدء':'Quick start'}</small></Link>)}</div><a className="postman-link" href="/siro-maps.postman_collection.json" download><Copy size={16}/><span><b>Postman Collection</b><small>{ar?'ملف آمن بدون مفاتيح حقيقية':'Safe collection with no embedded secrets'}</small></span><ArrowUpRight size={15}/></a></section></div>
|
||||
|
||||
<section className="console-panel keys-panel"><div className="panel-heading"><div><span>03</span><div><h2>{ar?'مفاتيح API':'API keys'}</h2><p>{ar?'أدر وصول تطبيقات الويب والموبايل.':'Manage access for web and mobile apps.'}</p></div></div><button className="button lime" onClick={()=>setModal(true)} disabled={loading}><Plus size={16}/>{ar?'مفتاح جديد':'New key'}</button></div>{loading?<div className="panel-loading"><Loader2 className="spin"/>{ar?'جاري تحميل المفاتيح...':'Loading keys...'}</div>:keys.length===0?<div className="empty-state"><KeyRound/><h3>{ar?'لا توجد مفاتيح بعد':'No keys yet'}</h3><p>{ar?'أنشئ مفتاحًا لتبدأ أول طلب من تطبيقك.':'Create a key to send your first application request.'}</p></div>:<div className="key-list">{keys.map(key=><article key={key.id}><span className={key.isActive?'key-status active':'key-status'}>{key.isActive?(ar?'نشط':'Active'):(ar?'متوقف':'Inactive')}</span><div><b>{key.name}</b><code dir="ltr">{showKeys[key.id]?key.key:'in_••••••••••••••••••••'}</code></div><small>{key.rateLimit} req/min</small><div><button onClick={()=>setShowKeys(v=>({...v,[key.id]:!v[key.id]}))} aria-label="Toggle key">{showKeys[key.id]?<EyeOff/>:<Eye/>}</button><button onClick={()=>navigator.clipboard.writeText(key.key)} aria-label="Copy key"><Copy/></button><button onClick={()=>void revoke(key)} aria-label="Revoke key"><Trash2/></button></div></article>)}</div>}</section>
|
||||
|
||||
{modal&&<div className="modal-backdrop" onMouseDown={()=>setModal(false)}><div className="console-modal" onMouseDown={e=>e.stopPropagation()}><span className="section-kicker">NEW CREDENTIAL</span><h2>{ar?'إنشاء مفتاح API':'Create API key'}</h2><label>{ar?'اسم المفتاح':'Key name'}<input value={name} onChange={e=>setName(e.target.value)} placeholder={ar?'تطبيق الإنتاج':'Production app'}/></label><label>{ar?'الطلبات في الدقيقة':'Requests per minute'}<input type="number" min="1" value={limit} onChange={e=>setLimit(Number(e.target.value))}/></label>{message&&<p className="form-error">{message}</p>}<div><button className="button ghost" onClick={()=>setModal(false)}>{ar?'إلغاء':'Cancel'}</button><button className="button lime" onClick={()=>void createKey()} disabled={busy}>{busy?<Loader2 className="spin"/>:<Plus/>}{ar?'إنشاء':'Create'}</button></div></div></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,136 +1,44 @@
|
||||
import {
|
||||
Copy,
|
||||
Code2,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ArrowUpRight, BookOpen, Check, Copy, Download, ExternalLink, FileJson, Search } from 'lucide-react';
|
||||
import { useLanguage } from '../i18n';
|
||||
|
||||
const endpoints = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/geocoding/search',
|
||||
desc: 'Forward geocoding: Find coordinates from a place name.',
|
||||
params: ['q (string)', 'lat (opt)', 'lng (opt)']
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/geocoding/reverse',
|
||||
desc: 'Reverse geocoding: Find addresses from coordinates.',
|
||||
params: ['lat (number)', 'lng (number)']
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/maps/tile/{z}/{x}/{y}',
|
||||
desc: 'Vector tile delivery for map rendering.',
|
||||
params: []
|
||||
}
|
||||
];
|
||||
const sdks = {
|
||||
js: { name:'JavaScript / TypeScript', install:'npm install intaleq-maps-gl', code:`import { IntaleqMap } from 'intaleq-maps-gl';
|
||||
|
||||
const Documentation = () => {
|
||||
const copyCode = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl text-gradient mb-2">Documentation</h1>
|
||||
<p className="text-slate-400">Guides, API reference, and examples to help you build faster</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Navigation Sidebar (Local) */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-500 mb-4 px-2">Getting Started</h4>
|
||||
<div className="space-y-1">
|
||||
{['Introduction', 'Authentication', 'SDK Installation', 'Best Practices'].map(item => (
|
||||
<button key={item} className="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors text-slate-300 hover:bg-white/5 hover:text-white">
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-500 mb-4 px-2">Core APIs</h4>
|
||||
<div className="space-y-1">
|
||||
{['Geocoding', 'Routing', 'Tileserver', 'Telemetry'].map(item => (
|
||||
<button key={item} className="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors text-slate-300 hover:bg-white/5 hover:text-white">
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="lg:col-span-3 space-y-12">
|
||||
{/* Quick Start Card */}
|
||||
<div className="glass p-8 rounded-3xl relative overflow-hidden">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-600/10 flex items-center justify-center text-blue-500">
|
||||
<Code2 size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Quick Integration</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-300 text-sm leading-relaxed mb-6">
|
||||
To start using the Intaleq Map API, initialize the MapLibre client with your secure API key.
|
||||
Our vector tiles are delivered in MVT format and optimized for sub-100ms delivery.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-950 rounded-2xl border border-slate-800 overflow-hidden relative group">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 bg-slate-900/50">
|
||||
<span className="text-xs font-mono text-slate-500">javascript</span>
|
||||
<button onClick={() => copyCode(`const map = new maplibregl.Map({
|
||||
const map = new IntaleqMap({
|
||||
container: 'map',
|
||||
style: 'https://map-saas.intaleqapp.com/api/maps/style.json?api_key=YOUR_KEY',
|
||||
center: [35.91, 31.95],
|
||||
zoom: 12
|
||||
});`)} className="hover:text-blue-500 transition-colors">
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-6 text-xs text-blue-400 font-mono leading-relaxed overflow-x-auto">
|
||||
{`const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: 'https://map-saas.intaleqapp.com/api/maps/style.json?api_key=YOUR_KEY',
|
||||
center: [35.91, 31.95],
|
||||
zoom: 12
|
||||
});`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
styleType: 'obsidian'
|
||||
});` },
|
||||
flutter: { name:'Flutter', install:'intaleq_maps: ^2.3.0', code:`IntaleqMap(
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: LatLng(31.9539, 35.9106),
|
||||
zoom: 14,
|
||||
),
|
||||
)` },
|
||||
swift: { name:'iOS / Swift', install:'import IntaleqMaps', code:`IntaleqMaps.shared.configure(
|
||||
apiKey: "YOUR_API_KEY"
|
||||
)
|
||||
|
||||
{/* API Reference List */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-6">API Endpoints</h3>
|
||||
<div className="space-y-4">
|
||||
{endpoints.map((ep, i) => (
|
||||
<div key={i} className="glass p-6 rounded-2xl border-l-4 border-blue-500/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-blue-500 text-white uppercase">{ep.method}</span>
|
||||
<code className="text-sm font-bold text-slate-200">{ep.path}</code>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-4">{ep.desc}</p>
|
||||
<div className="flex gap-2">
|
||||
{ep.params.map((p, j) => (
|
||||
<span key={j} className="text-[10px] px-2 py-0.5 rounded-full bg-slate-900 border border-slate-800 text-slate-400 lowercase font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="mt-8 flex items-center gap-2 text-sm text-blue-500 font-bold hover:underline" onClick={() => window.open('http://188.68.36.205:3200/api/docs', '_blank')}>
|
||||
Open Full Swagger Documentation <ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
let geocoding = IntaleqGeocodingService()
|
||||
// Search and reverse geocoding services` },
|
||||
kotlin: { name:'Android / Kotlin', install:'import com.intaleq.maps.*', code:`IntaleqMaps.initialize(
|
||||
context = applicationContext,
|
||||
apiKey = "YOUR_API_KEY"
|
||||
)
|
||||
|
||||
// Use IntaleqMapController for map actions` },
|
||||
};
|
||||
type SdkKey = keyof typeof sdks;
|
||||
|
||||
export default Documentation;
|
||||
export default function Documentation() {
|
||||
const { language } = useLanguage(); const ar=language==='ar';
|
||||
const [selected,setSelected]=useState<SdkKey>('js'); const [copied,setCopied]=useState('');
|
||||
const copy = async (value:string,label:string)=>{await navigator.clipboard.writeText(value);setCopied(label);window.setTimeout(()=>setCopied(''),1400)};
|
||||
return <div className="console-page docs-page"><div className="console-page-title"><div><span className="section-kicker">DOCUMENTATION</span><h1>{ar?'ابنِ على سيرو':'Build with Siro'}</h1><p>{ar?'اختر منصتك، أنشئ مفتاحًا، ثم جرّب البحث والخرائط والمسارات.':'Choose your platform, create a key, then test maps, search and routing.'}</p></div><a className="button ghost" href="/api/docs" target="_blank" rel="noreferrer">OpenAPI<ExternalLink size={15}/></a></div>
|
||||
<div className="docs-search"><Search/><input placeholder={ar?'ابحث في التوثيق...':'Search documentation...'}/><kbd>⌘ K</kbd></div>
|
||||
<section className="console-panel sdk-docs"><div className="panel-heading"><div><span>01</span><h2>{ar?'اختر حزمة التطوير':'Choose an SDK'}</h2></div></div><div className="sdk-doc-layout"><div className="sdk-tabs">{Object.entries(sdks).map(([key,value])=><button key={key} onClick={()=>setSelected(key as SdkKey)} className={selected===key?'active':''}><span>{key.slice(0,2).toUpperCase()}</span><b>{value.name}</b>{selected===key&&<Check/>}</button>)}</div><div className="code-card"><header><span>{sdks[selected].name}</span><button onClick={()=>void copy(sdks[selected].code,'code')}><Copy/>{copied==='code'?(ar?'تم النسخ':'Copied'):(ar?'نسخ':'Copy')}</button></header><div className="install-row"><code dir="ltr">{sdks[selected].install}</code><button onClick={()=>void copy(sdks[selected].install,'install')}><Copy/></button></div><pre dir="ltr"><code>{sdks[selected].code}</code></pre><small>{ar?'راجع ملفات الحزمة داخل المستودع قبل النشر العام، فمسارات التثبيت النهائية تعتمد على قناة توزيع كل حزمة.':'Review the package files before public release; final installation paths depend on each package distribution channel.'}</small></div></div></section>
|
||||
<div className="console-grid docs-bottom"><section className="console-panel"><div className="panel-heading"><div><span>02</span><h2>REST API</h2></div></div><div className="endpoint-list"><article><b><em>GET</em>/api/geocoding/search</b><p>{ar?'البحث عن الأماكن والعناوين':'Search places and addresses'}</p></article><article><b><em>GET</em>/api/geocoding/reverse</b><p>{ar?'تحويل الإحداثيات إلى عنوان':'Convert coordinates to an address'}</p></article><article><b><em>GET</em>/api/maps/style.json</b><p>{ar?'تحميل نمط الخريطة':'Load a map style'}</p></article></div><a className="text-link" href="/api/docs" target="_blank" rel="noreferrer">{ar?'افتح المرجع الكامل':'Open full reference'}<ArrowUpRight/></a></section><section className="console-panel postman-card"><FileJson/><span className="section-kicker">POSTMAN COLLECTION</span><h2>{ar?'جرّب الواجهات بدون كتابة تطبيق':'Test the APIs before writing an app'}</h2><p>{ar?'حمّل الملف، استورده إلى Postman، واضبط baseUrl وapiKey في متغيرات المجموعة. الملف لا يحتوي أسرارًا حقيقية.':'Download, import into Postman, then set baseUrl and apiKey in collection variables. No real secrets are embedded.'}</p><a className="button lime" href="/siro-maps.postman_collection.json" download><Download/>{ar?'تحميل JSON':'Download JSON'}</a></section></div>
|
||||
<section className="docs-note"><BookOpen/><div><b>{ar?'مسار المطور المقترح':'Recommended developer path'}</b><p>{ar?'التوثيق ← مفتاح API ← Postman أو Playground ← SDK ← مراقبة الاستخدام والاشتراك.':'Docs → API key → Postman or Playground → SDK → usage and plan monitoring.'}</p></div></section></div>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>منظومة انطلاق للسيادة المكانية والذكاء التكتيكي</title>
|
||||
<title>خرائط سيرو المكانية والذكاء التكتيكي | البوابة السيادية</title>
|
||||
|
||||
<!-- Apple SF Arabic & Modern Typography Stack -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"build:landing": "node scripts/build-landing.mjs",
|
||||
"prebuild": "npm run build:landing"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-leaflet": "^0.1.3",
|
||||
|
||||
+75
-558
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
const root = new URL('../', import.meta.url);
|
||||
const [content, css, js] = await Promise.all(['content.html', 'landing.css', 'interactions.js'].map(file => readFile(new URL(`src/landing/${file}`, root), 'utf8')));
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101411"><meta name="description" content="سيرو: بنية مكانية للمطورين والشركات. خرائط وبحث ومسارات وتجارب تضاريس قابلة للتخصيص."><title>SIRO — ابنِ ما يحرّك العالم</title><style>html{scroll-behavior:smooth}body{margin:0;background:#101411}*{box-sizing:border-box}${css}</style></head><body>${content}<script type="module">${js}\ninitializeLanding(document.querySelector('.sl'));</script></body></html>`;
|
||||
for (const file of ['public/landing.html', 'landing.html']) await writeFile(new URL(file, root), html);
|
||||
console.log('Generated both standalone landing pages from the shared source.');
|
||||
@@ -34,7 +34,7 @@ function App() {
|
||||
const [weatherAlerts, setWeatherAlerts] = useState<any[]>([]);
|
||||
|
||||
// Tourism & Heritage State
|
||||
const [tourismMode, setTourismMode] = useState(true);
|
||||
const [tourismMode, setTourismMode] = useState(false);
|
||||
const [heritageLandmarks, setHeritageLandmarks] = useState<HeritageLandmarkData[]>([]);
|
||||
const [selectedLandmark, setSelectedLandmark] = useState<HeritageLandmarkData | null>(null);
|
||||
const [showContributeModal, setShowContributeModal] = useState(false);
|
||||
@@ -67,8 +67,13 @@ function App() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHeritageLandmarks();
|
||||
}, []);
|
||||
if (tourismMode) {
|
||||
fetchHeritageLandmarks();
|
||||
} else {
|
||||
setHeritageLandmarks([]);
|
||||
setSelectedLandmark(null);
|
||||
}
|
||||
}, [tourismMode]);
|
||||
|
||||
const handleNavigateToGate = (lat: number, lng: number, name: string) => {
|
||||
setDestText(`${lat.toFixed(5)}, ${lng.toFixed(5)}`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
/** Shared behavior for the React view and the generated standalone document. */
|
||||
export function initializeLanding(root) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
root.addEventListener('click', async (event) => {
|
||||
const target = event.target.closest('a, button');
|
||||
if (!target || !root.contains(target)) return;
|
||||
if (target.dataset.scroll) {
|
||||
event.preventDefault();
|
||||
const section = root.querySelector(`#${target.dataset.scroll}`);
|
||||
section?.scrollIntoView({ behavior: reduced.matches ? 'instant' : 'smooth', block: 'start' });
|
||||
section?.setAttribute('tabindex', '-1');
|
||||
section?.focus({ preventScroll: true });
|
||||
}
|
||||
if (target.dataset.mode) {
|
||||
const mode = target.dataset.mode;
|
||||
root.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button === target)));
|
||||
root.querySelector('[data-scene]').dataset.scene = mode;
|
||||
const modes = {
|
||||
routes: ['ROUTE LAYER', 'اربط نقطة الانطلاق بالوجهة داخل تجربة تطبيقك.', '/#map'],
|
||||
terrain: ['TERRAIN LAYER', 'أضف الارتفاعات وسياق التضاريس إلى المشهد المكاني.', '/#terrain3d'],
|
||||
data: ['DATA LAYER', 'ضع مواقعك وبياناتك فوق الخريطة لفهم توزيعها.', '/#map']
|
||||
};
|
||||
const [label, description, href] = modes[mode];
|
||||
root.querySelector('[data-mode-label]').textContent = label;
|
||||
root.querySelector('.sl-mode-description').textContent = description;
|
||||
root.querySelector('[data-mode-link]').href = href;
|
||||
}
|
||||
if (target.classList.contains('sl-motion')) {
|
||||
const paused = root.classList.toggle('sl-paused');
|
||||
target.setAttribute('aria-pressed', String(paused));
|
||||
target.setAttribute('aria-label', paused ? 'تشغيل الحركة' : 'إيقاف الحركة');
|
||||
target.textContent = paused ? '▷' : 'Ⅱ';
|
||||
}
|
||||
if (target.classList.contains('sl-copy')) {
|
||||
const status = root.querySelector('.sl-copy-status');
|
||||
try {
|
||||
await navigator.clipboard.writeText(root.querySelector('.sl-code code').textContent);
|
||||
if (!signal.aborted) status.textContent = 'تم نسخ الكود';
|
||||
} catch {
|
||||
if (!signal.aborted) status.textContent = 'تعذّر النسخ التلقائي. يمكنك تحديد الكود ونسخه يدويًا.';
|
||||
}
|
||||
}
|
||||
}, { signal });
|
||||
return () => controller.abort();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -257,6 +257,9 @@ class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { has
|
||||
|
||||
function Root() {
|
||||
const getInitialHash = () => {
|
||||
if (window.location.hash) {
|
||||
return window.location.hash;
|
||||
}
|
||||
const p = window.location.pathname.toLowerCase();
|
||||
if (p.includes('landing')) {
|
||||
return '#landing';
|
||||
@@ -267,7 +270,7 @@ function Root() {
|
||||
if (p.includes('tactical') || p.includes('military')) {
|
||||
return '#tactical';
|
||||
}
|
||||
return window.location.hash || '#map';
|
||||
return '#map';
|
||||
};
|
||||
|
||||
const [hash, setHash] = useState(getInitialHash);
|
||||
@@ -284,7 +287,7 @@ function Root() {
|
||||
return () => window.removeEventListener('hashchange', on);
|
||||
}, []);
|
||||
|
||||
const isLanding = hash === '#landing' || hash === '#portal' || window.location.pathname.toLowerCase().includes('landing');
|
||||
const isLanding = hash === '#landing' || hash === '#portal';
|
||||
const isTourism = hash === '#tourism' || hash === '#heritage' || hash === '#tourist';
|
||||
const isTactical = hash === '#tactical' || hash === '#military' || hash === '#siro';
|
||||
const isTerrain3D = hash === '#terrain3d' || hash === '#3d' || hash === '#terrain';
|
||||
@@ -295,16 +298,16 @@ function Root() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MasterHeader hash={hash} />
|
||||
{!isLanding && <MasterHeader hash={hash} />}
|
||||
|
||||
<main style={{
|
||||
position: 'absolute',
|
||||
top: 52,
|
||||
top: isLanding ? 0 : 52,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: '100vw',
|
||||
height: 'calc(100vh - 52px)',
|
||||
height: isLanding ? '100vh' : 'calc(100vh - 52px)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{isLanding && (
|
||||
|
||||
+11
-1179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
# SIRO Maps — Crontab & Scheduled Jobs Directory
|
||||
# دليل وجدولة المهام الدورية والتشغيل الآلي لمنصة سيرو
|
||||
|
||||
This directory contains the central crontab configuration, installer, and documentation for all scheduled background jobs running on the SIRO Maps server.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview of Scheduled Jobs
|
||||
|
||||
| # | Task Name | Schedule | Script / Command | Log File | Description |
|
||||
|---|-----------|----------|-------------------|----------|-------------|
|
||||
| 1 | **Bi-Monthly Geospatial Sync** | Every 1st & 15th at 03:00 | `scripts/master_cron_sync.sh` | `logs/cron_places_sync.log` | Syncs OSM & Overture places for JO, IQ, EG, SY. Generates and classifies gates & entrances for complexes. Refreshes `unified_search_index` concurrently and flushes Redis cache. |
|
||||
| 2 | **Road Network & Routing Sync** | Every 1st, 10th, 20th at 01:00 | `scripts/update-data.sh` | `logs/cron_osm_update.log` | Downloads fresh OSM PBF for Jordan & Syria, updates PostGIS road tables, and rebuilds GraphHopper routing index. |
|
||||
| 3 | **Daily Database Backup** | Daily at 02:00 | `scripts/backup_db.sh` | `logs/cron_backup.log` | Backs up custom tables (`places_*`, `place_gates`, `users`, `api_keys`, `billing_*`, `telemetry_*`) with 7-day retention. |
|
||||
| 4 | **Weekly DB Vacuum & Analyze** | Every Sunday at 04:00 | `docker compose exec db vacuumdb` | `logs/cron_vacuum.log` | Reclaims space and updates PostgreSQL query planner statistics for fast spatial and trigram GIN queries. |
|
||||
| 5 | **Weekly Maintenance & Log Rotation** | Every Sunday at 05:00 | `scripts/cleanup_logs.sh` | `logs/cron_cleanup.log` | Truncates logs exceeding 50MB, deletes logs older than 14 days, and prunes dangling Docker images. |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Setup on Any Server / Migration Guide
|
||||
|
||||
When setting up a new server or migrating the platform:
|
||||
|
||||
```bash
|
||||
# 1. Navigate to the app directory
|
||||
cd /home/hamzadoctor/app
|
||||
|
||||
# 2. Run the Crontab Installer
|
||||
bash crontab/install_cron.sh
|
||||
```
|
||||
|
||||
The installer will automatically:
|
||||
1. Create required directories (`logs/` and `backups/db/`).
|
||||
2. Make all scripts in `scripts/` executable (`chmod +x`).
|
||||
3. Load all jobs from `crontab/crontab.txt` into the server's crontab.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 How to Monitor & Inspect Logs
|
||||
|
||||
```bash
|
||||
# View active crontab entries
|
||||
crontab -l
|
||||
|
||||
# Watch live output of places & gates sync
|
||||
tail -f /home/hamzadoctor/app/logs/cron_places_sync.log
|
||||
|
||||
# Check recent database backups
|
||||
ls -lh /home/hamzadoctor/app/backups/db/
|
||||
|
||||
# Check road network update log
|
||||
tail -n 100 /home/hamzadoctor/app/logs/cron_osm_update.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Manual Execution (Testing)
|
||||
|
||||
You can manually trigger any of these jobs at any time without waiting for the cron schedule:
|
||||
|
||||
```bash
|
||||
# Run Bi-Monthly Places & Gates Sync
|
||||
/bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh
|
||||
|
||||
# Run Database Backup
|
||||
/bin/bash /home/hamzadoctor/app/scripts/backup_db.sh
|
||||
|
||||
# Run Log & Docker Maintenance
|
||||
/bin/bash /home/hamzadoctor/app/scripts/cleanup_logs.sh
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# ==============================================================================
|
||||
# SIRO Maps — Sovereign Geospatial Platform Crontab Schedule
|
||||
# ==============================================================================
|
||||
# Base Directory: /home/hamzadoctor/app
|
||||
# Environment: Production
|
||||
# ==============================================================================
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
APP_DIR=/home/hamzadoctor/app
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 1. Bi-Monthly Geospatial Synchronization (Every 1st & 15th at 03:00 AM)
|
||||
# - Syncs OSM & Overture places for Jordan, Iraq, Egypt, Syria
|
||||
# - Generates and classifies Gates & Entrances (Hospitals, Malls, Unis, Hotels, Parks)
|
||||
# - Refreshes Materialized View (unified_search_index) concurrently
|
||||
# - Flushes Redis search cache
|
||||
# ------------------------------------------------------------------------------
|
||||
0 3 1,15 * * /bin/bash /home/hamzadoctor/app/scripts/master_cron_sync.sh >> /home/hamzadoctor/app/logs/cron_places_sync.log 2>&1
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 2. Road Network & Routing Update (Every 10 days at 01:00 AM)
|
||||
# - Downloads fresh OSM PBF for Jordan & Syria
|
||||
# - Rebuilds GraphHopper routing index & clears traffic cache
|
||||
# ------------------------------------------------------------------------------
|
||||
0 1 1,10,20 * * /bin/bash /home/hamzadoctor/app/scripts/update-data.sh >> /home/hamzadoctor/app/logs/cron_osm_update.log 2>&1
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 3. Daily Database Backup (Every Day at 02:00 AM)
|
||||
# - Exports custom places, place_gates, billing, users & telemetry tables
|
||||
# - Retains backups for 7 days
|
||||
# ------------------------------------------------------------------------------
|
||||
0 2 * * * /bin/bash /home/hamzadoctor/app/scripts/backup_db.sh >> /home/hamzadoctor/app/logs/cron_backup.log 2>&1
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 4. Weekly PostgreSQL Optimization (Every Sunday at 04:00 AM)
|
||||
# - Runs VACUUM ANALYZE across heavy spatial tables and indexes
|
||||
# ------------------------------------------------------------------------------
|
||||
0 4 * * 0 docker compose -f /home/hamzadoctor/app/docker-compose.yml exec -T db vacuumdb -U mapuser -d mapdb --analyze-in-stages >> /home/hamzadoctor/app/logs/cron_vacuum.log 2>&1
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 5. Weekly Log Rotation & Docker Maintenance (Every Sunday at 05:00 AM)
|
||||
# - Truncates logs > 50MB and removes logs older than 14 days
|
||||
# - Prunes dangling Docker images and builder caches
|
||||
# ------------------------------------------------------------------------------
|
||||
0 5 * * 0 /bin/bash /home/hamzadoctor/app/scripts/cleanup_logs.sh >> /home/hamzadoctor/app/logs/cron_cleanup.log 2>&1
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# SIRO Maps — Crontab Installer Script
|
||||
# ==============================================================================
|
||||
# Installs all scheduled jobs from crontab.txt into the current user's crontab.
|
||||
# Ensures all target scripts have executable permissions and directories exist.
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
CRON_FILE="${SCRIPT_DIR}/crontab.txt"
|
||||
|
||||
echo "🔧 Installing SIRO Maps Crontab..."
|
||||
echo "📂 App Directory: $APP_DIR"
|
||||
echo "📄 Crontab File: $CRON_FILE"
|
||||
|
||||
# 1. Ensure required directories exist
|
||||
mkdir -p "${APP_DIR}/logs"
|
||||
mkdir -p "${APP_DIR}/backups/db"
|
||||
|
||||
# 2. Make all scripts executable
|
||||
chmod +x "${APP_DIR}/scripts/"*.sh 2>/dev/null || true
|
||||
|
||||
# 3. Load crontab
|
||||
crontab "$CRON_FILE"
|
||||
|
||||
echo "✅ Crontab successfully installed!"
|
||||
echo "📋 Active Crontab Entries:"
|
||||
echo "------------------------------------------------------------------------------"
|
||||
crontab -l
|
||||
echo "------------------------------------------------------------------------------"
|
||||
+2
-2
@@ -194,7 +194,7 @@ services:
|
||||
- api
|
||||
- martin
|
||||
|
||||
# Commercial Dashboard: Vanilla HTML/CSS/JS (High Performance)
|
||||
# Unified public site + developer dashboard (React production build)
|
||||
dashboard:
|
||||
build:
|
||||
context: .
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
ports:
|
||||
- "3204:80"
|
||||
volumes:
|
||||
- ./apps/dashboard:/usr/share/nginx/html:ro
|
||||
- ./apps/dashboard/dist:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# سيرو: قراءة تجارية وخطة للتحقق من الفرصة
|
||||
|
||||
تاريخ المراجعة: 19 سبتمبر 2026. هذا تقييم أولي مبني على قراءة المستودع ومراجعة عروض المنافسين، وليس دراسة سوق ميدانية أو إثباتًا للجاهزية التشغيلية.
|
||||
|
||||
## الحكم الواضح
|
||||
|
||||
المشروع يستحق تجربة تجارية مركزة. وجود الخرائط والبحث والمسارات وحزم التطوير يختصر جزءًا من بناء المنتج؛ لكنه لا يثبت أن العملاء سيدفعون، أو أن الحل أرخص وأدق من البدائل. يصبح قابلًا للاستثمار بصورة أقوى عندما يثبت الاستخدام المدفوع المتكرر، وجودة بيانات محلية يصعب استبدالها، وتكلفة خدمة تسمح بهامش مستدام.
|
||||
|
||||
أنصح بالتموضع التالي: «بنية مكانية عربية لشركات التوصيل والعمليات الميدانية، مع تكامل محلي واستضافة مرنة». البداية بمدينة وقطاع محددين أسهل في القياس من منافسة جميع خدمات الخرائط عالميًا.
|
||||
|
||||
## ما الموجود بالفعل، وما لم نثبته؟
|
||||
|
||||
| القدرة | الدليل داخل المستودع | حدود الاستنتاج |
|
||||
|---|---|---|
|
||||
| البحث والإكمال التلقائي والترميز العكسي | apps/api/src/geocoding/geocoding.controller.ts | وجود الواجهات لا يثبت دقة النتائج أو شمول التغطية |
|
||||
| عرض الخرائط والمسارات | apps/web/src/components/MapComponent.tsx وapps/api/src/maps | يلزم اختبار ميداني للمسارات والتحديثات |
|
||||
| بيانات حركة المركبات | apps/api/src/telemetry | استقبال البيانات ليس منتج إدارة أساطيل مكتملًا |
|
||||
| حزم JavaScript وFlutter | packages/js-sdk وpackages/flutter-sdk | تحتاج تدقيق أمثلة التكامل وتوافق العناوين والمفاتيح واختبار النشر |
|
||||
| تضاريس وأدوات ميدانية | Terrain3DView.tsx وapps/api/src/tactical | لا يشكل إثباتًا للعمل الكامل بلا إنترنت أو اعتمادًا عسكريًا |
|
||||
| بيانات سياحية ومساهمات | apps/api/src/heritage وapps/api/src/community | يلزم تدقيق المصادر والحقوق والدقة ودورة التحديث |
|
||||
| مفاتيح مستأجرين واستخدام وفوترة | apps/api/src/auth وusage وbilling | لا يثبت تحصيل إيرادات أو جاهزية الفوترة تجاريًا |
|
||||
|
||||
لم تُفحص في هذه المهمة منظومة الإنتاج أو قاعدة البيانات الحية أو العقود أو الإيرادات أو حمل الخوادم. توجد أسماء Siro وIntaleq داخل المشروع؛ توحيد الاسم التجاري والتوثيق مهم قبل التسويق. كما أن SDK يستخدم عناوين ثابتة تختلف عن شكل بعض مسارات الخادم؛ يلزم اختبار تكامل قبل وعد المطور بإعداد فوري.
|
||||
|
||||
## أولويات الخدمات والزبائن
|
||||
|
||||
| الأولوية | المنتج المقترح | المشتري | القيمة التي نقيسها | العمل الإضافي |
|
||||
|---|---|---|---|---|
|
||||
| 1 | بحث عربي ونقاط وصول للتوصيل | شركات توصيل محلية ومتاجر لها أسطول | انخفاض فشل تحديد الوجهة والاتصالات بالسائق | مجموعة عناوين موثقة ومداخل مبانٍ وتصحيح مستمر |
|
||||
| 2 | واجهة خرائط ومسارات داخل التطبيقات | شركات برمجيات ومطورون | سرعة التكامل واستقرار الخدمة | توثيق ومفاتيح تجريبية وحصص استخدام وأمثلة مجربة |
|
||||
| 3 | عمليات ميدانية | صيانة ومرافق وخدمات بلدية | زمن توزيع المهمة والوصول وإكمالها | مهام وصلاحيات وسياج جغرافي وتنبيهات، وهي امتدادات مقترحة |
|
||||
| 4 | سياحة وتراث بعلامة العميل | مشغلو وجهات وفنادق وجهات سياحية | تفاعل الزوار والتحويل إلى حجز | محتوى موثق وربط بالحجز ودعم اللغات |
|
||||
| 5 | عقار واختيار مواقع تجارية | منصات عقار وسلاسل متاجر | جودة تحليل القرب والتغطية | بيانات أسعار وسكان وخدمات موثوقة ومرخصة |
|
||||
| لاحقًا | استضافة خاصة ومشاهد ميدانية متخصصة | مؤسسات كبيرة وجهات دفاعية | العزل والاعتمادية وتكامل الأنظمة | تدقيق أمني واختبارات انقطاع وتحديثات ودعم تعاقدي |
|
||||
|
||||
الاستخدامات الدفاعية الممكنة على مستوى المنتج: الخرائط التدريبية، إدارة الأصول والإمداد، وفهم التضاريس. أوصي بمسار مبيعات وتجربة منفصلين لهذه الجهات؛ لا نعرض «معتمد عسكريًا» أو «استقلالية مطلقة» قبل تحقق موثق. في البداية التجارية، التركيز على التوصيل أسهل لاختبار القيمة لأن وحدة العمل واضحة: رحلة وعنوان وتسليم.
|
||||
|
||||
أفكار لاحقة: خرائط المرافق، تغطية الاتصالات، متابعة أعمال المقاولين، مخاطر السيول، اكتشاف مواقع الشحن الكهربائي، ومساعد يستعلم عن بيانات العميل مكانيًا باللغة العربية. كل فكرة تتطلب بيانات وتحققًا خاصًا، ولا تُعرض كخدمة جاهزة بمجرد وجود خريطة.
|
||||
|
||||
## أين يمكن أن تكون الميزة التنافسية؟
|
||||
|
||||
Mapbox يقدم الخرائط والبحث والملاحة وحلولًا لقطاعات متعددة. كما أن Atlas يقدم استضافة خاصة وتشغيلًا معزولًا؛ لذلك «نستضيف ذاتيًا» وحدها ليست تميزًا حصريًا. المصادر: [Mapbox](https://www.mapbox.com/) و[Atlas](https://www.mapbox.com/atlas).
|
||||
|
||||
الميزة المحتملة لسيرو هي اجتماع جودة محلية قابلة للإثبات، وتكامل يناسب العميل، ودعم عربي قريب، وسعر يتناسب مع الاستخدام الفعلي. أثبتها بعينة مستقلة من عناوين العميل نفسه، لا بمجموعة منتقاة من أمثلة ناجحة. قارن نسبة نجاح البحث من أول محاولة، دقة مدخل المبنى، صلاحية المسار، وحداثة نقاط الاهتمام، تحت ظروف متساوية.
|
||||
|
||||
المكوّن المفتوح المصدر يسرّع البناء، لكنه لا يخلق وحده حاجزًا تنافسيًا. الحاجز الأقوى المحتمل: دورة تصحيح بيانات ميدانية موثوقة، تكاملات تصبح جزءًا من عمل العميل، وخبرة تشغيل تتراكم.
|
||||
|
||||
## كيف نربح؟
|
||||
|
||||
1. اشتراك للمطورين مع حصة شهرية واضحة وتكلفة تجاوز محسوبة، بعد قياس تكلفة الطلبات الفعلية.
|
||||
2. اشتراك للشركات مقابل استخدام المنصة والدعم؛ يمكن تسعير منتج الأساطيل بحسب المركبة النشطة عندما تكتمل وظائفه.
|
||||
3. رسوم تأسيس وتكامل منفصلة حتى لا تستهلك أعمال التخصيص هامش الاشتراك.
|
||||
4. عقد سنوي للاستضافة الخاصة والصيانة ومستويات الدعم، بعد إثبات القدرة التشغيلية.
|
||||
5. طبقات بيانات متخصصة مدفوعة عندما تتوافر حقوقها وجودتها.
|
||||
|
||||
لا أوصي الآن بسعر ثابت أو وعد «أوفر بمقدار 0.30 دولار لكل رحلة». الرحلة قد تستخدم عرض خريطة وبحثًا ومسارًا وتحديثات متعددة. المنافسون يبيعون خدمات ووحدات استخدام مختلفة، ومنها خطط وشرائح حجم. راجع [تسعير Google Maps Platform](https://mapsplatform.google.com/pricing/) عند إعداد مقارنة على حمل حقيقي.
|
||||
|
||||
احسب هامش المساهمة = الإيرادات − الاستضافة − نقل البيانات − مصادر البيانات المدفوعة − الدعم المباشر. وأضف رواتب الفريق والتسويق والتطوير عند حساب الربحية الكلية. «لا توجد فاتورة لمزود خارجي لكل طلب» لا تعني «التشغيل مجاني».
|
||||
|
||||
تقدير السوق الأولي يُبنى من الأسفل: عدد الشركات التي يمكن الوصول إليها في القطاع والمدينة × متوسط اشتراك مستعدّين لدفعه، ثم يُخصم احتمال التحويل. لا يوجد في هذه المراجعة دليل كافٍ لإعطاء حجم سوق أو تقييم للشركة.
|
||||
|
||||
## كيف نصل للمطور والمستثمر؟
|
||||
|
||||
المطور يريد مثالًا يعمل، توثيقًا واضحًا، زمنًا قصيرًا لأول نتيجة، تسعيرًا مفهومًا، واستقرارًا. اقترح ثلاثة تطبيقات مرجعية: البحث عن عنوان، تتبع مركبة، وعرض معالم. انشر شروحات عربية قصيرة مبنية على احتياجات عملية. راجع قابلية نشر الحزم والروابط قبل إعلان التثبيت بأمر واحد.
|
||||
|
||||
المستثمر يريد مشكلة مدفوعة الثمن، عملاء يستخدمون المنتج مجددًا، تكلفة خدمة قابلة للتوسع، وسببًا لعدم استبداله بسهولة. جهّز عرضًا يتضمن نتائج تجارب مدفوعة، والاحتفاظ والاستخدام والهامش، مع فصل التوقعات عن النتائج.
|
||||
|
||||
الدعاية الواسعة الآن قد تجلب زيارات أكثر من العملاء. ابدأ باستهداف شركات برمجيات وتوصيل محلية، وفيديو قصير يظهر المشكلة والحل، وتجربة محددة المعايير. بعد نجاح حالة موثقة، حوّلها إلى دراسة حالة بموافقة العميل، ثم وسّع الحملات. لا تنشر شعارات شركات على أنها عملاء قبل علاقة فعلية وإذن مناسب.
|
||||
|
||||
## خطة 90 يومًا — أهداف مقترحة وليست نتائج مضمونة
|
||||
|
||||
- الأيام 1–15: اختيار مدينة وقطاع؛ مقابلة نحو 10–15 عميلًا محتملاً؛ جمع مشكلات وعينات بيانات بموافقتهم؛ توحيد الهوية والعناوين البرمجية.
|
||||
- الأيام 16–30: إكمال مسار المطور من المفتاح إلى أول طلب ناجح؛ إعداد مجموعة تقييم مستقلة ومراقبة الأخطاء والتكلفة.
|
||||
- الأيام 31–60: تشغيل تجربتين أو ثلاث مع شركات مستعدة للدفع؛ قياس النجاح والدعم المطلوب والتكلفة لكل وحدة عمل.
|
||||
- الأيام 61–90: طلب تجديد أو تحويل إلى عقد؛ نشر دراسة حالة معتمدة؛ تحديد إن كان الاستخدام والهامش يبرران التوسع أو تعديل المنتج.
|
||||
|
||||
معايير القرار: هل يستخدم العميل المنتج أسبوعيًا؟ هل يدفع ويجدد؟ هل تنخفض مشكلة مهمة عنده؟ هل يمكن إضافة عميل دون مشروع برمجي جديد كامل؟ إذا كانت الإجابة لا، نضيّق نطاق المنتج أو نعيد اختيار القطاع قبل زيادة الإنفاق.
|
||||
|
||||
## الواجهة الموحدة للموقع ولوحة المطور
|
||||
|
||||
تطبيق `apps/dashboard` أصبح المدخل العام ولوحة المطور في تطبيق React واحد. المسار `/` يعرض المنتج والخدمات والبنية وحزم التطوير، والمسار `/console` يجمع مفاتيح API والمختبر والتوثيق والاشتراكات والتحليلات. اللغة العربية والإنجليزية تعملان من مفتاح واحد وتحفظان اختيار المستخدم.
|
||||
|
||||
المشهد الافتتاحي يعرض حركة ومسارًا وطبقات، ثم تأتي قدرات المنتج وحزم JavaScript/TypeScript وFlutter وSwift وKotlin وملف Postman آمن. الرسوم محاكاة معلنة للمفهوم وتعمل دون خدمة خرائط حية؛ المختبر داخل اللوحة يرسل طلب بحث فعليًا عند وجود جلسة ومفتاح.
|
||||
|
||||
أزيلت ادعاءات الأداء والتوفير والاتصال المطلق غير المدعومة. لا توجد إحصاءات عملاء مختلقة. يحافظ هذا على صدقية العرض أمام مطور أو مستثمر يسأل عن الأدلة. قبل حملة اكتساب مدفوعة، أضف قناة تواصل تجارية حقيقية، وصفحة تسعير مبنية على القياس، ومسار طلب تجربة مرتبط بنظام المتابعة.
|
||||
|
||||
يبني Docker تطبيق React إنتاجيًا ثم يقدمه عبر Nginx، مع تمرير `/api` إلى خدمة NestJS. حُدّثت روابط العودة من PayMob وBinance ورسالة تجاوز الحصة إلى `/console/billing`. التغييرات محلية ولم تنشر إلى الإنتاج.
|
||||
@@ -1,21 +1,13 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY apps/dashboard/package*.json ./
|
||||
RUN npm ci
|
||||
COPY apps/dashboard/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx static assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy static assets into nginx
|
||||
COPY apps/dashboard/*.html /usr/share/nginx/html/
|
||||
COPY apps/dashboard/js /usr/share/nginx/html/js/
|
||||
COPY apps/dashboard/css /usr/share/nginx/html/css/
|
||||
COPY apps/dashboard/assets /usr/share/nginx/html/assets/
|
||||
COPY apps/dashboard/uruk /usr/share/nginx/html/uruk/
|
||||
COPY apps/dashboard/data /usr/share/nginx/html/data/
|
||||
|
||||
# Fix networking issues by adding a simple redirect for spa if needed
|
||||
# But for hash-based routing, default index.html is enough.
|
||||
# We also need to handle the API proxy if we want to avoid CORS issues
|
||||
# However, the user's docker-compose has the API and Dashboard on different ports.
|
||||
# In a real production, we'd use Nginx to proxy /api to the backend.
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY infrastructure/docker/dashboard/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
@@ -16,6 +16,15 @@ server {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
|
||||
# Proxy Swagger documentation directly
|
||||
location /docs {
|
||||
proxy_pass http://api:3200/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Proxy API requests to the api service in docker
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -63,7 +63,7 @@ map.addIntaleqPolyline({
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation and API reference, visit the [Intaleq Documentation](https://map-dashbord.intaleqapp.com/dashboard.html#docs).
|
||||
For full documentation and API reference, visit the [Intaleq Documentation](https://map-dashboard.intaleqapp.com/console/documentation).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# SIRO Maps — Daily Database Backup Script
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
|
||||
BACKUP_DIR="${APP_DIR}/backups/db"
|
||||
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
|
||||
BACKUP_FILE="${BACKUP_DIR}/mapdb_custom_tables_${TIMESTAMP}.sql.gz"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "💾 [$(date '+%Y-%m-%d %H:%M:%S')] Starting SIRO Maps custom tables backup..."
|
||||
|
||||
# Dump custom tables: places_*, place_gates, billing, users, telemetry
|
||||
docker compose -f "${APP_DIR}/docker-compose.yml" exec -T db pg_dump -U mapuser -d mapdb \
|
||||
-t 'places_*' \
|
||||
-t 'place_gates' \
|
||||
-t 'users' \
|
||||
-t 'api_keys' \
|
||||
-t 'billing_*' \
|
||||
-t 'invoices' \
|
||||
-t 'subscriptions' \
|
||||
-t 'pricing_plans' \
|
||||
-t 'telemetry_*' \
|
||||
--no-owner --no-privileges | gzip > "$BACKUP_FILE"
|
||||
|
||||
echo "✅ Backup created successfully: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||
|
||||
# Prune backups older than RETENTION_DAYS
|
||||
echo "🧹 Pruning backups older than ${RETENTION_DAYS} days..."
|
||||
find "$BACKUP_DIR" -type f -name "*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
|
||||
|
||||
echo "🏁 [$(date '+%Y-%m-%d %H:%M:%S')] Backup process finished."
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# SIRO Maps — Weekly Maintenance & Log Cleanup Script
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
|
||||
LOG_DIR="${APP_DIR}/logs"
|
||||
RETENTION_DAYS=14
|
||||
|
||||
echo "🧹 [$(date '+%Y-%m-%d %H:%M:%S')] Starting weekly log and docker maintenance..."
|
||||
|
||||
# 1. Clean old log files
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
find "$LOG_DIR" -type f -name "*.log" -mtime +"$RETENTION_DAYS" -exec rm -f {} +
|
||||
echo "✅ Removed log files older than ${RETENTION_DAYS} days."
|
||||
fi
|
||||
|
||||
# 2. Truncate current active logs if they exceed 50MB
|
||||
for log in "$LOG_DIR"/*.log; do
|
||||
if [ -f "$log" ]; then
|
||||
size=$(stat -c%s "$log" 2>/dev/null || stat -f%z "$log" 2>/dev/null || echo 0)
|
||||
if [ "$size" -gt 52428800 ]; then # 50MB
|
||||
tail -n 10000 "$log" > "${log}.tmp" && mv "${log}.tmp" "$log"
|
||||
echo "✂️ Truncated large log file: $log"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Clean dangling docker images and build cache
|
||||
docker image prune -f --filter "until=168h" > /dev/null 2>&1 || true
|
||||
docker builder prune -f --keep-storage 2GB > /dev/null 2>&1 || true
|
||||
|
||||
echo "🏁 [$(date '+%Y-%m-%d %H:%M:%S')] Cleanup completed successfully."
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enrich Egypt Places Dataset (`places_egypt`) from:
|
||||
1. OpenStreetMap Named Points (`planet_osm_point`)
|
||||
2. OpenStreetMap Named Polygons (`planet_osm_polygon`)
|
||||
3. Overture Maps Places (`overture_place`)
|
||||
4. Deduplicate using spatial grid (<60m) against existing places_egypt
|
||||
5. Refresh `unified_search_index`
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import time
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000 # meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
delta_lambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
|
||||
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
def normalize_text(t):
|
||||
if not t:
|
||||
return ''
|
||||
s = t.strip()
|
||||
s = s.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي')
|
||||
return ' '.join(s.split()).lower()
|
||||
|
||||
def clean_sql_str(val):
|
||||
if val is None:
|
||||
return 'NULL'
|
||||
s = str(val).strip().replace("'", "''")
|
||||
return f"'{s}'"
|
||||
|
||||
OSM_CAT_MAP = {
|
||||
'village': 'قرية / تجمع سكاني',
|
||||
'town': 'بلدة / مركز',
|
||||
'city': 'مدينة',
|
||||
'suburb': 'حي / ضاحية',
|
||||
'neighbourhood': 'منطقة سكنية',
|
||||
'hamlet': 'نجع / كفر',
|
||||
'locality': 'موضع / منطقة',
|
||||
'school': 'مدرسة / تعليم',
|
||||
'college': 'كلية',
|
||||
'university': 'جامعة',
|
||||
'kindergarten': 'روضة أطفال',
|
||||
'supermarket': 'سوبرماركت وهايبرماركت',
|
||||
'convenience': 'محل بقالة',
|
||||
'bakery': 'مخبز وفرن',
|
||||
'butcher': 'جزارة وقصابة',
|
||||
'mall': 'مركز تسوق ومول',
|
||||
'place_of_worship': 'مسجد / دار عبادة',
|
||||
'mosque': 'جامع / مسجد',
|
||||
'clinic': 'مركز طبي وعيادة',
|
||||
'pharmacy': 'صيدلية',
|
||||
'hospital': 'مستشفى',
|
||||
'doctors': 'عيادات تخصصية',
|
||||
'dentist': 'عيادة أسنان',
|
||||
'restaurant': 'مطعم',
|
||||
'cafe': 'مقهى وكافيه',
|
||||
'fast_food': 'مأكولات سريعة',
|
||||
'fuel': 'محطة وقود وبنزين',
|
||||
'bank': 'مصرف / بنك',
|
||||
'atm': 'ماكينة صراف آلي ATM',
|
||||
'bureau_de_change': 'مكتب صرافة',
|
||||
'police': 'قسم شرطة ونقطة أمنية',
|
||||
'fire_station': 'وحدة مطافئ / دفاع مدني',
|
||||
'post_office': 'مكتب بريد',
|
||||
'attraction': 'معلم سياحي وتاريخي',
|
||||
'hotel': 'فندق وإقامة',
|
||||
'guest_house': 'نُزل واستضافة',
|
||||
'museum': 'متحف وآثار',
|
||||
'park': 'حديقة ومنتزه عام',
|
||||
'stadium': 'استاد وملعب رياضي',
|
||||
'sports_centre': 'مركز ونادي رياضي',
|
||||
'car_repair': 'ورشة صيانة سيارات',
|
||||
'car_wash': 'مغسلة سيارات',
|
||||
'company': 'شركة ومقر أعمال',
|
||||
'government': 'جهة حكومية ورسمية',
|
||||
'mobile_phone': 'متجر هواتف ومحمول',
|
||||
'clothes': 'متجر ملابس وأزياء',
|
||||
'shoes': 'متجر أحذية',
|
||||
'hairdresser': 'صالون حلاقة وتجميل',
|
||||
'optician': 'بصريات ونظارات',
|
||||
'hardware': 'أدوات بناء وتجهيزات',
|
||||
}
|
||||
|
||||
OVERTURE_CAT_MAP = {
|
||||
'restaurant': 'مطعم',
|
||||
'cafe': 'مقهى وكافيه',
|
||||
'coffee_shop': 'مقهى وكوفي شوب',
|
||||
'casual_eatery': 'مأكولات سريعة وخفيفة',
|
||||
'bakery': 'مخبز ومعجنات',
|
||||
'food_and_beverage_store': 'بقالة ومواد غذائية',
|
||||
'supermarket': 'سوبرماركت وهايبرماركت',
|
||||
'fashion_and_apparel_store': 'متجر ملابس وأزياء',
|
||||
'shoe_store': 'متجر أحذية',
|
||||
'jewelry_store': 'مجوهرات وحلي',
|
||||
'electronics_store': 'متجر إلكترونيات وأجهزة',
|
||||
'mobile_phone_store': 'متجر هواتف ذكية',
|
||||
'hardware_home_and_garden_store': 'مواد بناء ومستلزمات منزلية',
|
||||
'home_service': 'خدمات منزلية وصيانة',
|
||||
'personal_or_beauty_service': 'صالون ومركز تجميل',
|
||||
'hospital': 'مستشفى / مركز طبي',
|
||||
'dental_clinic': 'عيادة أسنان',
|
||||
'specialized_health_care': 'عيادة تخصصية',
|
||||
'pharmacy': 'صيدلية',
|
||||
'hotel': 'فندق وإقامة',
|
||||
'place_of_learning': 'منشأة تعليمية',
|
||||
'school': 'مدرسة',
|
||||
'college_university': 'جامعة / كلية',
|
||||
'historic_site': 'موقع تاريخي وأثري',
|
||||
'religious_organization': 'مؤسسة دينية',
|
||||
'mosque': 'مسجد / جامع',
|
||||
'real_estate_service': 'مكتب عقاري',
|
||||
'professional_service': 'خدمات مهنية واستشارية',
|
||||
'travel_service': 'مكتب سياحة وسفر',
|
||||
'corporate_or_business_office': 'شركة ومؤسسة أعمال',
|
||||
'car_repair': 'صيانة وتصليح سيارات',
|
||||
'fuel_station': 'محطة وقود',
|
||||
'bank': 'مصرف / بنك',
|
||||
'government_office': 'مصلحة وهيئة حكومية',
|
||||
}
|
||||
|
||||
def map_category(cat_str, source_type='osm'):
|
||||
if not cat_str:
|
||||
return 'مكان ونقطة اهتمام'
|
||||
clean = str(cat_str).strip().lower()
|
||||
if source_type == 'overture':
|
||||
return OVERTURE_CAT_MAP.get(clean, 'مكان عام وتجاري')
|
||||
return OSM_CAT_MAP.get(clean, 'مكان عام')
|
||||
|
||||
def get_grid_cell(lat, lon, cell_size=0.01):
|
||||
return (int(lat / cell_size), int(lon / cell_size))
|
||||
|
||||
def is_duplicate(name_norm, lat, lon, grid, max_dist_m=60.0):
|
||||
d_lat_thresh = 0.0006
|
||||
d_lon_thresh = 0.0007
|
||||
c_x, c_y = get_grid_cell(lat, lon)
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
cell = (c_x + dx, c_y + dy)
|
||||
if cell in grid:
|
||||
for existing_name, ex_lat, ex_lon in grid[cell]:
|
||||
if abs(lat - ex_lat) > d_lat_thresh or abs(lon - ex_lon) > d_lon_thresh:
|
||||
continue
|
||||
dist = haversine(lat, lon, ex_lat, ex_lon)
|
||||
if dist <= max_dist_m:
|
||||
if (name_norm == existing_name or
|
||||
name_norm in existing_name or
|
||||
existing_name in name_norm):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_to_grid(name_norm, lat, lon, grid):
|
||||
cell = get_grid_cell(lat, lon)
|
||||
grid[cell].append((name_norm, lat, lon))
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Enrich Egypt places from OSM & Overture.")
|
||||
parser.add_argument("--db-host", default="127.0.0.1")
|
||||
parser.add_argument("--db-port", type=int, default=5432)
|
||||
parser.add_argument("--db-user", default="mapuser")
|
||||
parser.add_argument("--db-pass", default="mappass")
|
||||
parser.add_argument("--db-name", default="mapdb")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
import pg8000.native
|
||||
|
||||
print("=" * 70)
|
||||
print("🇪🇬 SIRO Maps — Comprehensive Egypt Places Enrichment & Deduplication")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"🔌 Connecting to PostgreSQL at {args.db_host}:{args.db_port} ({args.db_name})...")
|
||||
con = pg8000.native.Connection(
|
||||
user=args.db_user,
|
||||
password=args.db_pass,
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
database=args.db_name
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
grid = defaultdict(list)
|
||||
|
||||
# 1. Load existing places_egypt
|
||||
print("\n📦 Step 1: Loading existing places_egypt into spatial index...")
|
||||
existing = con.run("""
|
||||
SELECT id, name, latitude, longitude
|
||||
FROM places_egypt
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
""")
|
||||
print(f" -> Loaded {len(existing):,} existing places.")
|
||||
for row in existing:
|
||||
pid, name, lat, lon = row
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
norm_name = normalize_text(name)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ Spatial grid built with {sum(len(v) for v in grid.values()):,} items.")
|
||||
|
||||
# Egypt Bounding Box: 21.8 <= lat <= 31.8, 24.7 <= lon <= 36.9
|
||||
EG_MIN_LON, EG_MIN_LAT = 24.7, 21.8
|
||||
EG_MAX_LON, EG_MAX_LAT = 36.9, 31.8
|
||||
|
||||
# 2. Harvest from planet_osm_point in Egypt
|
||||
print("\n📍 Step 2: Harvesting from OpenStreetMap Named Points (planet_osm_point)...")
|
||||
osm_pts = con.run(f"""
|
||||
SELECT
|
||||
name,
|
||||
COALESCE(amenity, shop, tourism, historic, place, leisure, office, building, highway, "natural") as cat,
|
||||
ST_Y(ST_Transform(way, 4326)) as lat,
|
||||
ST_X(ST_Transform(way, 4326)) as lon
|
||||
FROM planet_osm_point
|
||||
WHERE name IS NOT NULL
|
||||
AND TRIM(name) != ''
|
||||
AND way && ST_Transform(ST_MakeEnvelope({EG_MIN_LON}, {EG_MIN_LAT}, {EG_MAX_LON}, {EG_MAX_LAT}, 4326), 3857);
|
||||
""")
|
||||
print(f" -> Found {len(osm_pts):,} candidates in planet_osm_point for Egypt.")
|
||||
|
||||
osm_pts_to_insert = []
|
||||
osm_pts_dups = 0
|
||||
|
||||
for r in osm_pts:
|
||||
name = str(r[0]).strip()
|
||||
cat_raw = r[1]
|
||||
lat = float(r[2])
|
||||
lon = float(r[3])
|
||||
|
||||
if not (EG_MIN_LAT <= lat <= EG_MAX_LAT and EG_MIN_LON <= lon <= EG_MAX_LON):
|
||||
continue
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
osm_pts_dups += 1
|
||||
continue
|
||||
|
||||
category = map_category(cat_raw, 'osm')
|
||||
pop_score = 65 if cat_raw in ('village', 'town', 'city', 'hospital', 'university', 'mosque') else 40
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': 'مصر',
|
||||
'address': f"{category}, مصر",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'osm_point',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
osm_pts_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ OSM Points Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {osm_pts_dups:,}")
|
||||
print(f" - Net New Places: {len(osm_pts_to_insert):,}")
|
||||
|
||||
# 3. Harvest from planet_osm_polygon in Egypt
|
||||
print("\n🏛️ Step 3: Harvesting from OpenStreetMap Named Polygons (planet_osm_polygon)...")
|
||||
osm_polys = con.run(f"""
|
||||
SELECT
|
||||
name,
|
||||
COALESCE(amenity, shop, tourism, historic, place, leisure, building, landuse) as cat,
|
||||
ST_Y(ST_Centroid(ST_Transform(way, 4326))) as lat,
|
||||
ST_X(ST_Centroid(ST_Transform(way, 4326))) as lon
|
||||
FROM planet_osm_polygon
|
||||
WHERE name IS NOT NULL
|
||||
AND TRIM(name) != ''
|
||||
AND (amenity IS NOT NULL OR shop IS NOT NULL OR tourism IS NOT NULL OR historic IS NOT NULL
|
||||
OR place IS NOT NULL OR leisure IS NOT NULL OR landuse IN ('commercial', 'retail', 'industrial', 'cemetery', 'religious'))
|
||||
AND way && ST_Transform(ST_MakeEnvelope({EG_MIN_LON}, {EG_MIN_LAT}, {EG_MAX_LON}, {EG_MAX_LAT}, 4326), 3857);
|
||||
""")
|
||||
print(f" -> Found {len(osm_polys):,} candidates in planet_osm_polygon for Egypt.")
|
||||
|
||||
osm_polys_to_insert = []
|
||||
osm_polys_dups = 0
|
||||
|
||||
for r in osm_polys:
|
||||
name = str(r[0]).strip()
|
||||
cat_raw = r[1]
|
||||
lat = float(r[2])
|
||||
lon = float(r[3])
|
||||
|
||||
if not (EG_MIN_LAT <= lat <= EG_MAX_LAT and EG_MIN_LON <= lon <= EG_MAX_LON):
|
||||
continue
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
osm_polys_dups += 1
|
||||
continue
|
||||
|
||||
category = map_category(cat_raw, 'osm')
|
||||
pop_score = 70 if cat_raw in ('hospital', 'university', 'mall', 'stadium', 'attraction') else 45
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': 'مصر',
|
||||
'address': f"{category}, مصر",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'osm_polygon',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
osm_polys_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ OSM Polygons Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {osm_polys_dups:,}")
|
||||
print(f" - Net New Places: {len(osm_polys_to_insert):,}")
|
||||
|
||||
# 4. Harvest from overture_place in Egypt
|
||||
print("\n🗺️ Step 4: Harvesting from Overture Maps Places (overture_place)...")
|
||||
ovt_rows = con.run(f"""
|
||||
SELECT
|
||||
name_primary,
|
||||
basic_category,
|
||||
addresses,
|
||||
confidence,
|
||||
ST_Y(ST_Centroid(location::geometry)) as lat,
|
||||
ST_X(ST_Centroid(location::geometry)) as lon
|
||||
FROM overture_place
|
||||
WHERE name_primary IS NOT NULL
|
||||
AND TRIM(name_primary) != ''
|
||||
AND ST_Within(location::geometry, ST_MakeEnvelope({EG_MIN_LON}, {EG_MIN_LAT}, {EG_MAX_LON}, {EG_MAX_LAT}, 4326));
|
||||
""")
|
||||
print(f" -> Found {len(ovt_rows):,} candidates in overture_place for Egypt.")
|
||||
|
||||
ovt_to_insert = []
|
||||
ovt_dups = 0
|
||||
|
||||
for r in ovt_rows:
|
||||
name = str(r[0]).strip()
|
||||
cat_str = r[1]
|
||||
addr_list = r[2]
|
||||
confidence = float(r[3] or 0.8)
|
||||
lat = float(r[4])
|
||||
lon = float(r[5])
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
ovt_dups += 1
|
||||
continue
|
||||
|
||||
city = 'مصر'
|
||||
addr_text = None
|
||||
if addr_list and isinstance(addr_list, list) and len(addr_list) > 0:
|
||||
a = addr_list[0]
|
||||
if isinstance(a, dict):
|
||||
city = a.get('locality') or a.get('region') or 'مصر'
|
||||
addr_text = a.get('freeform')
|
||||
|
||||
category = map_category(cat_str, 'overture')
|
||||
pop_score = min(100, int(confidence * 60 + 20))
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': city,
|
||||
'address': addr_text or f"{city}, مصر",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'overture_maps',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
ovt_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ Overture Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {ovt_dups:,}")
|
||||
print(f" - Net New Places: {len(ovt_to_insert):,}")
|
||||
|
||||
all_new = osm_pts_to_insert + osm_polys_to_insert + ovt_to_insert
|
||||
print(f"\n🌟 Total Net New Places to Insert into places_egypt: {len(all_new):,}")
|
||||
|
||||
if args.dry_run:
|
||||
print("💡 Dry run complete. No database changes made.")
|
||||
return
|
||||
|
||||
# 5. Insert into places_egypt
|
||||
print(f"\n🚀 Step 5: Inserting {len(all_new):,} places into places_egypt...")
|
||||
t_insert = time.time()
|
||||
batch_size = 2500
|
||||
total_inserted = 0
|
||||
|
||||
con.run("BEGIN;")
|
||||
for i in range(0, len(all_new), batch_size):
|
||||
batch = all_new[i:i + batch_size]
|
||||
values = []
|
||||
for r in batch:
|
||||
c_name = clean_sql_str(r['name'])
|
||||
c_name_ar = clean_sql_str(r['name_ar'])
|
||||
c_name_en = clean_sql_str(r['name_en'])
|
||||
c_cat = clean_sql_str(r['category'])
|
||||
c_city = clean_sql_str(r['city'])
|
||||
c_addr = clean_sql_str(r['address'])
|
||||
c_src = clean_sql_str(r['source'])
|
||||
lat = r['lat']
|
||||
lon = r['lon']
|
||||
pop = r['popularity_score']
|
||||
|
||||
line = (
|
||||
f"({c_name}, {c_name_ar}, {c_name_en}, {lat:.7f}, {lon:.7f}, {c_cat}, "
|
||||
f"{c_city}, {c_addr}, {c_src}, {pop}, "
|
||||
f"ST_SetSRID(ST_MakePoint({lon:.7f}, {lat:.7f}), 4326))"
|
||||
)
|
||||
values.append(line)
|
||||
|
||||
sql = (
|
||||
"INSERT INTO places_egypt ("
|
||||
" name, name_ar, name_en, latitude, longitude, category,"
|
||||
" city, address, source, popularity_score, location"
|
||||
") VALUES " + ",\n".join(values) + ";"
|
||||
)
|
||||
con.run(sql)
|
||||
total_inserted += len(batch)
|
||||
print(f" -> Progress: {total_inserted:,} / {len(all_new):,} places inserted...")
|
||||
|
||||
con.run("COMMIT;")
|
||||
print(f"✅ Ingestion committed successfully in {time.time() - t_insert:.2f}s!")
|
||||
|
||||
# 6. Refresh Materialized View
|
||||
print("\n🔄 Step 6: Refreshing materialized view unified_search_index concurrently...")
|
||||
t_mv = time.time()
|
||||
try:
|
||||
con.run("REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;")
|
||||
print(f" ✓ unified_search_index refreshed in {time.time() - t_mv:.2f}s!")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Concurrent refresh notice: {e}, attempting standard refresh...")
|
||||
con.run("REFRESH MATERIALIZED VIEW unified_search_index;")
|
||||
print(f" ✓ unified_search_index refreshed!")
|
||||
|
||||
con.run("ANALYZE places_egypt;")
|
||||
con.run("ANALYZE unified_search_index;")
|
||||
|
||||
total_count = con.run("SELECT count(*) FROM places_egypt;")[0][0]
|
||||
print(f"\n🎯 Total Places in places_egypt: {total_count:,}")
|
||||
con.close()
|
||||
print(f"🎉 Egypt places enrichment completed in {time.time() - t0:.2f}s!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive Iraq Places Enrichment & Deduplication Script.
|
||||
Enriches `places_iraq` table by harvesting and deduplicating:
|
||||
1. Overture Maps Places (`overture_place`)
|
||||
2. OpenStreetMap Named Points (`planet_osm_point`)
|
||||
3. OpenStreetMap Named Polygons (`planet_osm_polygon`)
|
||||
4. Administrative boundaries & Governorates linking
|
||||
5. Concurrently refreshes `unified_search_index`
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import time
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000 # meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
delta_lambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
|
||||
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
def normalize_text(t):
|
||||
if not t:
|
||||
return ''
|
||||
s = t.strip()
|
||||
s = s.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي')
|
||||
return ' '.join(s.split()).lower()
|
||||
|
||||
def clean_sql_str(val):
|
||||
if val is None:
|
||||
return 'NULL'
|
||||
s = str(val).strip().replace("'", "''")
|
||||
return f"'{s}'"
|
||||
|
||||
# Category Translations
|
||||
OVERTURE_CAT_MAP = {
|
||||
'restaurant': 'مطعم',
|
||||
'cafe': 'مقهى وكافيه',
|
||||
'coffee_shop': 'مقهى وكوفي شوب',
|
||||
'casual_eatery': 'وجبات سريعة ومأكولات خفيفة',
|
||||
'bakery': 'مخبز ومعجنات',
|
||||
'food_and_beverage_store': 'بقالة ومواد غذائية',
|
||||
'supermarket': 'سوبرماركت وهايبرماركت',
|
||||
'fashion_and_apparel_store': 'متجر ألبسة وأزياء',
|
||||
'shoe_store': 'متجر أحذية',
|
||||
'jewelry_store': 'مجوهرات وحلي',
|
||||
'electronics_store': 'متجر إلكترونيات وأجهزة',
|
||||
'mobile_phone_store': 'متجر هواتف ذكية',
|
||||
'hardware_home_and_garden_store': 'متجر مواد بناء ومستلزمات منزلية',
|
||||
'home_service': 'خدمات منزلية وصيانة',
|
||||
'personal_or_beauty_service': 'صالون ومركز تجميل',
|
||||
'hair_salon': 'صالون حلاقة وتزيين',
|
||||
'hospital': 'مستشفى / مركز طبي',
|
||||
'dental_clinic': 'عيادة طب أسنان',
|
||||
'specialized_health_care': 'عيادة استشارية متخصصة',
|
||||
'pharmacy': 'صيدلية',
|
||||
'hotel': 'فندق وإقامة',
|
||||
'place_of_learning': 'مؤسسة تعليمية',
|
||||
'school': 'مدرسة',
|
||||
'college_university': 'كلية / جامعة',
|
||||
'historic_site': 'موقع تاريخي وأثري',
|
||||
'religious_organization': 'دار عبادة ومؤسسة دينية',
|
||||
'mosque': 'مسجد / جامع',
|
||||
'real_estate_service': 'مكتب عقاري',
|
||||
'professional_service': 'خدمات مهنية واستشارية',
|
||||
'travel_service': 'مكتب سياحة وسفر',
|
||||
'corporate_or_business_office': 'مكتب شركة ومؤسسة أعمال',
|
||||
'car_repair': 'صيانة وتصليح سيارات',
|
||||
'car_dealership': 'معرض ووكالة سيارات',
|
||||
'fuel_station': 'محطة وقود',
|
||||
'bank': 'مصرف / بنك',
|
||||
'government_office': 'دائرة حكومية ورسمية',
|
||||
'law_firm': 'مكتب محاماة واستشارات قانونية',
|
||||
'sports_club': 'نادي ومجمع رياضي',
|
||||
'park': 'منتزه وحديقة عامة',
|
||||
}
|
||||
|
||||
OSM_CAT_MAP = {
|
||||
'village': 'قرية / تجمع سكاني',
|
||||
'town': 'بلدة / قضاء',
|
||||
'city': 'مدينة',
|
||||
'suburb': 'حي / ضاحية',
|
||||
'neighbourhood': 'حي سكني',
|
||||
'hamlet': 'قرية صغيرة',
|
||||
'locality': 'منطقة / موضع',
|
||||
'school': 'مدرسة / تعليم',
|
||||
'college': 'كلية',
|
||||
'university': 'جامعة',
|
||||
'kindergarten': 'روضة أطفال',
|
||||
'supermarket': 'سوبرماركت ومواد غذائية',
|
||||
'convenience': 'محل بقالة',
|
||||
'bakery': 'مخبز',
|
||||
'butcher': 'ملحمة وقصابة',
|
||||
'mall': 'مركز تسوق ومول',
|
||||
'place_of_worship': 'مسجد / دار عبادة',
|
||||
'mosque': 'مسجد / جامع',
|
||||
'clinic': 'مركز صحي وعيادة',
|
||||
'pharmacy': 'صيدلية',
|
||||
'hospital': 'مستشفى',
|
||||
'doctors': 'عيادة طبيب',
|
||||
'dentist': 'عيادة أسنان',
|
||||
'restaurant': 'مطعم',
|
||||
'cafe': 'مقهى وكافيه',
|
||||
'fast_food': 'وجبات سريعة',
|
||||
'fuel': 'محطة وقود',
|
||||
'bank': 'مصرف / بنك',
|
||||
'atm': 'صراف آلي',
|
||||
'bureau_de_change': 'مكتب صرافة وتحويل مالي',
|
||||
'police': 'مركز شرطة وأمن',
|
||||
'fire_station': 'دفاع مدني / إطفاء',
|
||||
'post_office': 'مكتب بريد',
|
||||
'attraction': 'معلم سياحي',
|
||||
'hotel': 'فندق',
|
||||
'guest_house': 'نُزل واستضافة',
|
||||
'museum': 'متحف / تراث',
|
||||
'park': 'حديقة ومنتزه',
|
||||
'stadium': 'ملعب / مجمع رياضي',
|
||||
'sports_centre': 'مركز رياضي',
|
||||
'car_repair': 'تصليح سيارات',
|
||||
'car_wash': 'مغسلة سيارات',
|
||||
'company': 'شركة ومؤسسة',
|
||||
'government': 'دائرة حكومية',
|
||||
'mobile_phone': 'متجر هواتف ونقالات',
|
||||
'clothes': 'متجر ألبسة',
|
||||
'shoes': 'متجر أحذية',
|
||||
'hairdresser': 'صالون حلاقة',
|
||||
'optician': 'بصريات ونظارات',
|
||||
'hardware': 'مواد بناء وتجهيزات',
|
||||
}
|
||||
|
||||
def map_overture_category(cat_str):
|
||||
if not cat_str:
|
||||
return 'مكان ونقطة اهتمام'
|
||||
clean = cat_str.strip().lower()
|
||||
return OVERTURE_CAT_MAP.get(clean, 'مكان عام وتجاري')
|
||||
|
||||
def map_osm_category(val):
|
||||
if not val:
|
||||
return 'نقطة اهتمام'
|
||||
clean = str(val).strip().lower()
|
||||
return OSM_CAT_MAP.get(clean, 'مكان عام')
|
||||
|
||||
def get_grid_cell(lat, lon, cell_size=0.01):
|
||||
# ~1.1km cell size
|
||||
return (int(lat / cell_size), int(lon / cell_size))
|
||||
|
||||
def is_duplicate(name_norm, lat, lon, grid, max_dist_m=60.0):
|
||||
# 60 meters approx threshold in degrees
|
||||
d_lat_thresh = 0.0006
|
||||
d_lon_thresh = 0.0007
|
||||
c_x, c_y = get_grid_cell(lat, lon)
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
cell = (c_x + dx, c_y + dy)
|
||||
if cell in grid:
|
||||
for existing_name, ex_lat, ex_lon in grid[cell]:
|
||||
if abs(lat - ex_lat) > d_lat_thresh or abs(lon - ex_lon) > d_lon_thresh:
|
||||
continue
|
||||
dist = haversine(lat, lon, ex_lat, ex_lon)
|
||||
if dist <= max_dist_m:
|
||||
# Check name similarity
|
||||
if (name_norm == existing_name or
|
||||
name_norm in existing_name or
|
||||
existing_name in name_norm):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_to_grid(name_norm, lat, lon, grid):
|
||||
cell = get_grid_cell(lat, lon)
|
||||
grid[cell].append((name_norm, lat, lon))
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Enrich Iraq places comprehensively from Overture & OSM.")
|
||||
parser.add_argument("--db-host", default="127.0.0.1", help="Database host")
|
||||
parser.add_argument("--db-port", type=int, default=5432, help="Database port")
|
||||
parser.add_argument("--db-user", default="mapuser", help="Database user")
|
||||
parser.add_argument("--db-pass", default="mappass", help="Database password")
|
||||
parser.add_argument("--db-name", default="mapdb", help="Database name")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Perform extraction and deduplication only without inserting.")
|
||||
args = parser.parse_args()
|
||||
|
||||
import pg8000.native
|
||||
|
||||
print("=" * 70)
|
||||
print("🇮🇶 SIRO Maps — Comprehensive Iraq Places Enrichment & Deduplication")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"🔌 Connecting to PostgreSQL at {args.db_host}:{args.db_port} ({args.db_name})...")
|
||||
con = pg8000.native.Connection(
|
||||
user=args.db_user,
|
||||
password=args.db_pass,
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
database=args.db_name
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
grid = defaultdict(list)
|
||||
|
||||
# 1. Load existing places into spatial hash grid
|
||||
print("\n📦 Step 1: Loading existing places_iraq into spatial index for deduplication...")
|
||||
existing = con.run("""
|
||||
SELECT id, name, latitude, longitude
|
||||
FROM places_iraq
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
""")
|
||||
print(f" -> Loaded {len(existing):,} existing places.")
|
||||
for row in existing:
|
||||
pid, name, lat, lon = row
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
norm_name = normalize_text(name)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ Spatial grid built with {sum(len(v) for v in grid.values()):,} items.")
|
||||
|
||||
# 2. Harvest from overture_place
|
||||
print("\n🗺️ Step 2: Harvesting from Overture Maps Places (overture_place)...")
|
||||
overture_rows = con.run("""
|
||||
SELECT
|
||||
name_primary,
|
||||
basic_category,
|
||||
addresses,
|
||||
confidence,
|
||||
ST_Y(ST_Centroid(location::geometry)) as lat,
|
||||
ST_X(ST_Centroid(location::geometry)) as lon
|
||||
FROM overture_place
|
||||
WHERE name_primary IS NOT NULL
|
||||
AND TRIM(name_primary) != ''
|
||||
AND ST_Within(location::geometry, ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326));
|
||||
""")
|
||||
print(f" -> Found {len(overture_rows):,} candidates in overture_place for Iraq.")
|
||||
|
||||
overture_to_insert = []
|
||||
overture_dups = 0
|
||||
|
||||
for r in overture_rows:
|
||||
name = str(r[0]).strip()
|
||||
cat_str = r[1]
|
||||
addr_list = r[2]
|
||||
confidence = float(r[3] or 0.8)
|
||||
lat = float(r[4])
|
||||
lon = float(r[5])
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
overture_dups += 1
|
||||
continue
|
||||
|
||||
# Extract address details if available
|
||||
city = 'العراق'
|
||||
addr_text = None
|
||||
if addr_list and isinstance(addr_list, list) and len(addr_list) > 0:
|
||||
a = addr_list[0]
|
||||
if isinstance(a, dict):
|
||||
city = a.get('locality') or a.get('region') or 'العراق'
|
||||
addr_text = a.get('freeform')
|
||||
|
||||
category = map_overture_category(cat_str)
|
||||
pop_score = min(100, int(confidence * 60 + 20))
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': city,
|
||||
'address': addr_text or f"{city}, العراق",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'overture_maps',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
overture_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ Overture Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {overture_dups:,}")
|
||||
print(f" - Net New Places: {len(overture_to_insert):,}")
|
||||
|
||||
# 3. Harvest from planet_osm_point
|
||||
print("\n📍 Step 3: Harvesting from OpenStreetMap Named Points (planet_osm_point)...")
|
||||
osm_point_rows = con.run("""
|
||||
SELECT
|
||||
name,
|
||||
COALESCE(amenity, shop, tourism, historic, place, leisure, office, building, highway, "natural") as cat,
|
||||
ST_Y(ST_Transform(way, 4326)) as lat,
|
||||
ST_X(ST_Transform(way, 4326)) as lon
|
||||
FROM planet_osm_point
|
||||
WHERE name IS NOT NULL
|
||||
AND TRIM(name) != ''
|
||||
AND way && ST_Transform(ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326), 3857);
|
||||
""")
|
||||
print(f" -> Found {len(osm_point_rows):,} candidates in planet_osm_point for Iraq.")
|
||||
|
||||
osm_point_to_insert = []
|
||||
osm_point_dups = 0
|
||||
|
||||
for r in osm_point_rows:
|
||||
name = str(r[0]).strip()
|
||||
cat_raw = r[1]
|
||||
lat = float(r[2])
|
||||
lon = float(r[3])
|
||||
|
||||
# Bounds check
|
||||
if not (28.8 <= lat <= 37.5 and 38.8 <= lon <= 48.8):
|
||||
continue
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
osm_point_dups += 1
|
||||
continue
|
||||
|
||||
category = map_osm_category(cat_raw)
|
||||
pop_score = 65 if cat_raw in ('village', 'town', 'city', 'hospital', 'university', 'mosque') else 40
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': 'العراق',
|
||||
'address': f"{category}, العراق",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'osm_point',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
osm_point_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ OSM Points Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {osm_point_dups:,}")
|
||||
print(f" - Net New Places: {len(osm_point_to_insert):,}")
|
||||
|
||||
# 4. Harvest from planet_osm_polygon (POIs)
|
||||
print("\n🏛️ Step 4: Harvesting from OpenStreetMap Named Polygons (planet_osm_polygon)...")
|
||||
osm_poly_rows = con.run("""
|
||||
SELECT
|
||||
name,
|
||||
COALESCE(amenity, shop, tourism, historic, place, leisure, building, landuse) as cat,
|
||||
ST_Y(ST_Centroid(ST_Transform(way, 4326))) as lat,
|
||||
ST_X(ST_Centroid(ST_Transform(way, 4326))) as lon
|
||||
FROM planet_osm_polygon
|
||||
WHERE name IS NOT NULL
|
||||
AND TRIM(name) != ''
|
||||
AND (amenity IS NOT NULL OR shop IS NOT NULL OR tourism IS NOT NULL OR historic IS NOT NULL
|
||||
OR place IS NOT NULL OR leisure IS NOT NULL OR landuse IN ('commercial', 'retail', 'industrial', 'cemetery', 'religious'))
|
||||
AND way && ST_Transform(ST_MakeEnvelope(38.8, 28.8, 48.8, 37.5, 4326), 3857);
|
||||
""")
|
||||
print(f" -> Found {len(osm_poly_rows):,} candidates in planet_osm_polygon for Iraq.")
|
||||
|
||||
osm_poly_to_insert = []
|
||||
osm_poly_dups = 0
|
||||
|
||||
for r in osm_poly_rows:
|
||||
name = str(r[0]).strip()
|
||||
cat_raw = r[1]
|
||||
lat = float(r[2])
|
||||
lon = float(r[3])
|
||||
|
||||
if not (28.8 <= lat <= 37.5 and 38.8 <= lon <= 48.8):
|
||||
continue
|
||||
|
||||
norm_name = normalize_text(name)
|
||||
if len(norm_name) < 2:
|
||||
continue
|
||||
|
||||
if is_duplicate(norm_name, lat, lon, grid, max_dist_m=60.0):
|
||||
osm_poly_dups += 1
|
||||
continue
|
||||
|
||||
category = map_osm_category(cat_raw)
|
||||
pop_score = 70 if cat_raw in ('hospital', 'university', 'mall', 'stadium', 'attraction') else 45
|
||||
|
||||
item = {
|
||||
'name': name,
|
||||
'name_ar': name,
|
||||
'name_en': name,
|
||||
'category': category,
|
||||
'city': 'العراق',
|
||||
'address': f"{category}, العراق",
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'source': 'osm_polygon',
|
||||
'popularity_score': pop_score
|
||||
}
|
||||
osm_poly_to_insert.append(item)
|
||||
add_to_grid(norm_name, lat, lon, grid)
|
||||
|
||||
print(f" ✓ OSM Polygons Processing Complete:")
|
||||
print(f" - Duplicates Filtered: {osm_poly_dups:,}")
|
||||
print(f" - Net New Places: {len(osm_poly_to_insert):,}")
|
||||
|
||||
# Total new places to insert
|
||||
all_new_places = overture_to_insert + osm_point_to_insert + osm_poly_to_insert
|
||||
print(f"\n🌟 Total Net New Places to Insert: {len(all_new_places):,}")
|
||||
|
||||
if args.dry_run:
|
||||
print("💡 Dry run requested. Exiting without database insertion.")
|
||||
return
|
||||
|
||||
# 5. Database Batch Insertion
|
||||
print(f"\n🚀 Step 5: Inserting {len(all_new_places):,} places into places_iraq...")
|
||||
t_insert = time.time()
|
||||
batch_size = 2500
|
||||
total_inserted = 0
|
||||
|
||||
con.run("BEGIN;")
|
||||
for i in range(0, len(all_new_places), batch_size):
|
||||
batch = all_new_places[i:i + batch_size]
|
||||
values = []
|
||||
for r in batch:
|
||||
c_name = clean_sql_str(r['name'])
|
||||
c_name_ar = clean_sql_str(r['name_ar'])
|
||||
c_name_en = clean_sql_str(r['name_en'])
|
||||
c_cat = clean_sql_str(r['category'])
|
||||
c_city = clean_sql_str(r['city'])
|
||||
c_addr = clean_sql_str(r['address'])
|
||||
c_src = clean_sql_str(r['source'])
|
||||
lat = r['lat']
|
||||
lon = r['lon']
|
||||
pop = r['popularity_score']
|
||||
|
||||
line = (
|
||||
f"({c_name}, {c_name_ar}, {c_name_en}, {lat:.7f}, {lon:.7f}, {c_cat}, "
|
||||
f"{c_city}, {c_addr}, {c_src}, {pop}, "
|
||||
f"ST_SetSRID(ST_MakePoint({lon:.7f}, {lat:.7f}), 4326))"
|
||||
)
|
||||
values.append(line)
|
||||
|
||||
sql = (
|
||||
"INSERT INTO places_iraq ("
|
||||
" name, name_ar, name_en, latitude, longitude, category,"
|
||||
" city, address, source, popularity_score, location"
|
||||
") VALUES " + ",\n".join(values) + ";"
|
||||
)
|
||||
con.run(sql)
|
||||
total_inserted += len(batch)
|
||||
print(f" -> Progress: {total_inserted:,} / {len(all_new_places):,} places inserted...")
|
||||
|
||||
con.run("COMMIT;")
|
||||
print(f"✅ Ingestion committed successfully in {time.time() - t_insert:.2f}s!")
|
||||
|
||||
# 6. Link Administrative Hierarchy (Governorates & Districts)
|
||||
print("\n🏛️ Step 6: Linking administrative hierarchy (Governorates & Districts)...")
|
||||
t_admin = time.time()
|
||||
con.run("""
|
||||
UPDATE places_iraq p
|
||||
SET
|
||||
governorate_id = ab.id,
|
||||
city = COALESCE(ab.name_ar, ab.name, p.city)
|
||||
FROM admin_boundaries ab
|
||||
WHERE ab.country_code = 'IQ'
|
||||
AND ab.admin_level = 4
|
||||
AND (p.governorate_id IS NULL OR p.city = 'العراق')
|
||||
AND ST_Within(p.location::geometry, ab.geom::geometry);
|
||||
""")
|
||||
print(f" ✓ Linked governorates in {time.time() - t_admin:.2f}s.")
|
||||
|
||||
# 7. Refresh Materialized View
|
||||
print("\n🔄 Step 7: Refreshing materialized view unified_search_index concurrently...")
|
||||
t_mv = time.time()
|
||||
try:
|
||||
con.run("REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;")
|
||||
print(f" ✓ unified_search_index refreshed in {time.time() - t_mv:.2f}s!")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Concurrent refresh notice: {e}, attempting standard refresh...")
|
||||
con.run("REFRESH MATERIALIZED VIEW unified_search_index;")
|
||||
print(f" ✓ unified_search_index refreshed!")
|
||||
|
||||
# 8. Analyze
|
||||
print("\n⚡ Step 8: Optimizing database query planner with ANALYZE...")
|
||||
con.run("ANALYZE places_iraq;")
|
||||
con.run("ANALYZE unified_search_index;")
|
||||
|
||||
# Final Statistics
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 Final Verification & Statistics for Iraq:")
|
||||
print("=" * 70)
|
||||
total_count = con.run("SELECT count(*) FROM places_iraq;")[0][0]
|
||||
print(f" 🎯 Total Places in places_iraq: {total_count:,}")
|
||||
|
||||
by_source = con.run("""
|
||||
SELECT source, count(*)
|
||||
FROM places_iraq
|
||||
GROUP BY source
|
||||
ORDER BY count(*) DESC;
|
||||
""")
|
||||
print(" 📈 Breakdown by Source:")
|
||||
for src, cnt in by_source:
|
||||
print(f" - {src}: {cnt:,}")
|
||||
|
||||
by_gov = con.run("""
|
||||
SELECT city, count(*)
|
||||
FROM places_iraq
|
||||
WHERE city IS NOT NULL AND city != ''
|
||||
GROUP BY city
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 10;
|
||||
""")
|
||||
print("\n 🏙️ Top Iraqi Governorates:")
|
||||
for gov, cnt in by_gov:
|
||||
print(f" - {gov}: {cnt:,} places")
|
||||
|
||||
con.close()
|
||||
print(f"\n🎉 Total script execution time: {time.time() - t0:.2f}s")
|
||||
print("✨ Iraq geospatial database is now vibrantly enriched!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# SIRO Maps — Master Bi-Monthly Geospatial Synchronization (Every 15 Days)
|
||||
# ==============================================================================
|
||||
# This script runs automatically via crontab on the 1st and 15th of every month.
|
||||
# It checks and syncs:
|
||||
# 1. OpenStreetMap & Overture Maps for Jordan, Iraq, Syria, and Egypt.
|
||||
# 2. Gate & Entrance mapping (Hospitals, Malls, Universities, Hotels, Parks).
|
||||
# 3. Administrative boundary resolution (Governorates & Districts).
|
||||
# 4. Materialized view concurrent refresh (unified_search_index).
|
||||
# 5. Redis search cache flushing.
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
|
||||
LOG_DIR="${APP_DIR}/logs"
|
||||
LOG_FILE="${LOG_DIR}/cron_places_sync.log"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
echo "==============================================================================" >> "$LOG_FILE"
|
||||
echo "📅 [$(date '+%Y-%m-%d %H:%M:%S')] Starting Bi-Monthly SIRO Maps Sync" >> "$LOG_FILE"
|
||||
echo "==============================================================================" >> "$LOG_FILE"
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 1. Sync Iraq
|
||||
echo "🇮🇶 Syncing Iraq places..." >> "$LOG_FILE"
|
||||
python3 -u scripts/enrich_iraq_comprehensive.py >> "$LOG_FILE" 2>&1 || echo "⚠️ Iraq sync had warnings" >> "$LOG_FILE"
|
||||
|
||||
# 2. Sync Egypt
|
||||
echo "🇪🇬 Syncing Egypt places..." >> "$LOG_FILE"
|
||||
python3 -u scripts/enrich_egypt_places.py >> "$LOG_FILE" 2>&1 || echo "⚠️ Egypt sync had warnings" >> "$LOG_FILE"
|
||||
|
||||
# 3. Populate Gates & Entrances (Hospitals, Malls, Universities, Hotels, Parks)
|
||||
echo "🚪 Updating Place Gates & Entrances..." >> "$LOG_FILE"
|
||||
python3 -u scripts/populate_place_gates.py --country all >> "$LOG_FILE" 2>&1 || echo "⚠️ Gates update had warnings" >> "$LOG_FILE"
|
||||
|
||||
# 4. Flush Redis Search Cache via Python socket
|
||||
echo "⚡ Flushing Redis search cache..." >> "$LOG_FILE"
|
||||
python3 -c "
|
||||
import socket
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.connect(('127.0.0.1', 6381))
|
||||
s.sendall(b'FLUSHDB\r\n')
|
||||
print('Redis FLUSHDB:', s.recv(1024).decode().strip())
|
||||
except Exception as e:
|
||||
print('Redis flush error:', e)
|
||||
" >> "$LOG_FILE" 2>&1
|
||||
|
||||
echo "✅ [$(date '+%Y-%m-%d %H:%M:%S')] Bi-Monthly Sync Completed Successfully!" >> "$LOG_FILE"
|
||||
echo "==============================================================================" >> "$LOG_FILE"
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate and deduplicate Iraq Places Dataset into places_iraq table on PostgreSQL.
|
||||
Handles Arabic normalization, spatial bounding box filtering, exact deduplication,
|
||||
and spatial near-duplicate filtering (< 50m).
|
||||
Refreshes unified_search_index afterwards.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import gzip
|
||||
import math
|
||||
import time
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000 # meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
delta_lambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
|
||||
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
def normalize_text(t):
|
||||
if not t:
|
||||
return ''
|
||||
s = t.strip()
|
||||
# Normalize Arabic alef, teh marbuta, etc.
|
||||
s = s.replace('أ', 'ا').replace('إ', 'ا').replace('آ', 'ا').replace('ة', 'ه').replace('ى', 'ي')
|
||||
return ' '.join(s.split()).lower()
|
||||
|
||||
def clean_sql_str(val):
|
||||
if val is None:
|
||||
return 'NULL'
|
||||
s = str(val).strip().replace("'", "''")
|
||||
return f"'{s}'"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Migrate Iraq places dataset with deduplication.")
|
||||
parser.add_argument("--file", default="", help="Path to CSV or CSV.GZ file.")
|
||||
parser.add_argument("--db-host", default="127.0.0.1", help="Database host")
|
||||
parser.add_argument("--db-port", type=int, default=5432, help="Database port")
|
||||
parser.add_argument("--db-user", default="mapuser", help="Database user")
|
||||
parser.add_argument("--db-pass", default="mappass", help="Database password")
|
||||
parser.add_argument("--db-name", default="mapdb", help="Database name")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Perform validation and deduplication only without inserting.")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find file
|
||||
file_path = args.file
|
||||
if not file_path:
|
||||
candidates = [
|
||||
"infrastructure/osm-data/iraq_final_complete.csv.gz",
|
||||
"infrastructure/osm-data/iraq_final_complete.csv",
|
||||
"/home/hamzadoctor/app/infrastructure/osm-data/iraq_final_complete.csv.gz",
|
||||
"/home/hamzadoctor/app/infrastructure/osm-data/iraq_final_complete.csv",
|
||||
"iraq_final_complete.csv.gz",
|
||||
"iraq_final_complete.csv"
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c):
|
||||
file_path = c
|
||||
break
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
print(f"❌ Error: Dataset file not found: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📂 Processing dataset: {file_path}")
|
||||
open_fn = gzip.open if file_path.endswith('.gz') else open
|
||||
mode = 'rt' if file_path.endswith('.gz') else 'r'
|
||||
|
||||
total_read = 0
|
||||
empty_name = 0
|
||||
out_of_bounds = 0
|
||||
valid_records = []
|
||||
|
||||
t0 = time.time()
|
||||
with open_fn(file_path, mode, encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
total_read += 1
|
||||
name = row.get('name', '').strip()
|
||||
if not name:
|
||||
empty_name += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
lat = float(row['latitude'])
|
||||
lng = float(row['longitude'])
|
||||
except Exception:
|
||||
out_of_bounds += 1
|
||||
continue
|
||||
|
||||
# Iraq bounding box check (28.0 to 39.0 Lat, 38.0 to 50.0 Lng)
|
||||
if not (28.0 <= lat <= 39.0 and 38.0 <= lng <= 50.0):
|
||||
out_of_bounds += 1
|
||||
continue
|
||||
|
||||
category = row.get('category_queried', '').strip() or 'مكان عام'
|
||||
sector = row.get('sector', '').strip()
|
||||
gov = row.get('governorate', '').strip() or 'العراق'
|
||||
area = row.get('area', '').strip()
|
||||
maps_url = row.get('maps_url', '').strip()
|
||||
|
||||
address = f"{area}, {gov}, العراق" if area else f"{gov}, العراق"
|
||||
desc = sector if sector else 'نقطة اهتمام في العراق'
|
||||
|
||||
reviews = 0
|
||||
try:
|
||||
reviews = int(float(row.get('reviews_count', 0)))
|
||||
except Exception:
|
||||
reviews = 0
|
||||
|
||||
rating = 0.0
|
||||
try:
|
||||
rating = float(row.get('rating', 0))
|
||||
except Exception:
|
||||
rating = 0.0
|
||||
|
||||
pop_score = min(100, int(reviews * 0.2 + rating * 5))
|
||||
|
||||
valid_records.append({
|
||||
'name': name,
|
||||
'norm_name': normalize_text(name),
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'category': category,
|
||||
'city': gov,
|
||||
'neighbourhood': area,
|
||||
'address': address,
|
||||
'description': desc,
|
||||
'popularity_score': pop_score,
|
||||
'maps_url': maps_url,
|
||||
'reviews': reviews
|
||||
})
|
||||
|
||||
print(f"📊 Initial parse complete in {time.time() - t0:.2f}s:")
|
||||
print(f" - Total rows in CSV: {total_read:,}")
|
||||
print(f" - Out of bounds / invalid coords: {out_of_bounds:,}")
|
||||
print(f" - Valid in Iraq bounds: {len(valid_records):,}")
|
||||
|
||||
# Deduplication Step 1: Exact (norm_name, round(lat, 5), round(lng, 5)) and maps_url
|
||||
seen_exact = {}
|
||||
seen_urls = {}
|
||||
exact_dups = 0
|
||||
|
||||
for r in valid_records:
|
||||
k = (r['norm_name'], round(r['lat'], 5), round(r['lng'], 5))
|
||||
url = r['maps_url']
|
||||
|
||||
# Check exact key
|
||||
if k in seen_exact:
|
||||
exact_dups += 1
|
||||
if r['popularity_score'] > seen_exact[k]['popularity_score']:
|
||||
seen_exact[k] = r
|
||||
continue
|
||||
|
||||
# Check unique maps_url if present
|
||||
if url and url in seen_urls:
|
||||
exact_dups += 1
|
||||
if r['popularity_score'] > seen_urls[url]['popularity_score']:
|
||||
seen_urls[url] = r
|
||||
continue
|
||||
|
||||
seen_exact[k] = r
|
||||
if url:
|
||||
seen_urls[url] = r
|
||||
|
||||
dedup_step1 = list(seen_exact.values())
|
||||
print(f"🔍 Step 1 Deduplication (Exact coords / URL):")
|
||||
print(f" - Removed {exact_dups} duplicate records.")
|
||||
print(f" - Remaining: {len(dedup_step1):,}")
|
||||
|
||||
# Deduplication Step 2: Spatial near-duplicates (< 50m with identical normalized name)
|
||||
by_norm_name = defaultdict(list)
|
||||
for r in dedup_step1:
|
||||
by_norm_name[r['norm_name']].append(r)
|
||||
|
||||
final_records = []
|
||||
near_dups_filtered = 0
|
||||
|
||||
for norm_name, items in by_norm_name.items():
|
||||
if len(items) == 1:
|
||||
final_records.append(items[0])
|
||||
else:
|
||||
items.sort(key=lambda x: x['popularity_score'], reverse=True)
|
||||
kept = []
|
||||
for candidate in items:
|
||||
is_dup = False
|
||||
for existing in kept:
|
||||
d = haversine(candidate['lat'], candidate['lng'], existing['lat'], existing['lng'])
|
||||
if d < 50:
|
||||
is_dup = True
|
||||
near_dups_filtered += 1
|
||||
break
|
||||
if not is_dup:
|
||||
kept.append(candidate)
|
||||
final_records.extend(kept)
|
||||
|
||||
print(f"🎯 Step 2 Deduplication (Spatial near-duplicates < 50m):")
|
||||
print(f" - Filtered out {near_dups_filtered} near-duplicates.")
|
||||
print(f" - Final unique clean places to migrate: {len(final_records):,}")
|
||||
|
||||
if args.dry_run:
|
||||
print("💡 Dry run complete. No database changes made.")
|
||||
return
|
||||
|
||||
# Database Migration
|
||||
import pg8000.native
|
||||
print(f"\n🔌 Connecting to PostgreSQL at {args.db_host}:{args.db_port} ({args.db_name})...")
|
||||
con = pg8000.native.Connection(
|
||||
user=args.db_user,
|
||||
password=args.db_pass,
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
database=args.db_name
|
||||
)
|
||||
|
||||
t_db = time.time()
|
||||
print("🗑️ Removing previous checkpoint imports from places_iraq...")
|
||||
con.run("BEGIN;")
|
||||
con.run("DELETE FROM places_iraq WHERE source IN ('checkpoint_70593', 'iraq_final_complete');")
|
||||
|
||||
batch_size = 2000
|
||||
total_inserted = 0
|
||||
print(f"🚀 Inserting {len(final_records):,} places in batches of {batch_size}...")
|
||||
|
||||
for i in range(0, len(final_records), batch_size):
|
||||
batch = final_records[i:i + batch_size]
|
||||
values = []
|
||||
for r in batch:
|
||||
c_name = clean_sql_str(r['name'])
|
||||
c_cat = clean_sql_str(r['category'])
|
||||
c_city = clean_sql_str(r['city'])
|
||||
c_area = clean_sql_str(r['neighbourhood'])
|
||||
c_addr = clean_sql_str(r['address'])
|
||||
c_desc = clean_sql_str(r['description'])
|
||||
lat = r['lat']
|
||||
lng = r['lng']
|
||||
pop = r['popularity_score']
|
||||
|
||||
line = (
|
||||
f"({c_name}, {c_name}, {lat:.7f}, {lng:.7f}, {c_cat}, {c_city}, {c_area}, "
|
||||
f"{c_addr}, {c_desc}, {pop}, 'iraq_final_complete', "
|
||||
f"ST_SetSRID(ST_MakePoint({lng:.7f}, {lat:.7f}), 4326))"
|
||||
)
|
||||
values.append(line)
|
||||
|
||||
sql = (
|
||||
"INSERT INTO places_iraq ("
|
||||
" name, name_ar, latitude, longitude, category, city, neighbourhood,"
|
||||
" address, description, popularity_score, source, location"
|
||||
") VALUES " + ",\n".join(values) + ";"
|
||||
)
|
||||
con.run(sql)
|
||||
total_inserted += len(batch)
|
||||
print(f" -> Inserted {total_inserted:,} / {len(final_records):,} places...")
|
||||
|
||||
con.run("COMMIT;")
|
||||
print(f"✅ Ingestion committed successfully in {time.time() - t_db:.2f}s!")
|
||||
|
||||
# Refresh materialized view
|
||||
print("🔄 Refreshing materialized view unified_search_index concurrently...")
|
||||
t_mv = time.time()
|
||||
con.run("REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;")
|
||||
print(f"✅ unified_search_index refreshed in {time.time() - t_mv:.2f}s!")
|
||||
|
||||
# Analyze table
|
||||
print("⚡ Running ANALYZE on places_iraq...")
|
||||
con.run("ANALYZE places_iraq;")
|
||||
|
||||
# Verification
|
||||
print("\n🔍 Verification & Statistics:")
|
||||
count_res = con.run("SELECT count(*) FROM places_iraq;")[0][0]
|
||||
gov_stats = con.run("""
|
||||
SELECT city, count(*)
|
||||
FROM places_iraq
|
||||
WHERE source = 'iraq_final_complete'
|
||||
GROUP BY city
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 10;
|
||||
""")
|
||||
print(f" - Total rows in places_iraq: {count_res:,}")
|
||||
print(" - Top governorates:")
|
||||
for gov, cnt in gov_stats:
|
||||
print(f" * {gov}: {cnt:,} places")
|
||||
|
||||
# Sample Geocoding search test
|
||||
test_queries = ['المنصور بغداد', 'البصرة', 'جامعة الموصل', 'قلعة اربيل', 'النجف']
|
||||
print("\n🧪 Testing search on unified_search_index:")
|
||||
for q in test_queries:
|
||||
norm_q = normalize_text(q)
|
||||
results = con.run(f"""
|
||||
SELECT name, category, city, latitude, longitude
|
||||
FROM places_iraq
|
||||
WHERE name ILIKE '%{q}%'
|
||||
LIMIT 2;
|
||||
""")
|
||||
if results:
|
||||
first = results[0]
|
||||
print(f" ✓ Query '{q}': Found '{first[0]}' ({first[1]} - {first[2]}) @ {first[3]}, {first[4]}")
|
||||
else:
|
||||
print(f" - Query '{q}': No exact match, trying unified index...")
|
||||
|
||||
con.close()
|
||||
print("\n🎉 Iraq dataset migration completed successfully!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Populate and enrich `place_gates` table for major complexes:
|
||||
- Hospitals (Main Gate, Emergency & Ambulance Gate, Outpatient/Service Gate)
|
||||
- Malls & Shopping Centers (Main Entrance, Parking Entrance, Delivery/Service Gate)
|
||||
- Universities & Colleges (Main Gate, North Gate, South/Student Gate)
|
||||
- Hotels & Resorts (Main Entrance, Valet/Parking Gate, Service Gate)
|
||||
- Parks & Public Gardens (Main Entrance, Family Entrance, Secondary Gate)
|
||||
|
||||
Harvests from:
|
||||
1. Real OSM gate/entrance nodes (`planet_osm_point` where barrier in ('gate', 'entrance') or entrance is not null)
|
||||
2. Complex boundary polygons and perimeter road-facing access points
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import time
|
||||
import argparse
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000 # meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
delta_lambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(delta_phi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2)**2
|
||||
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
def clean_sql_str(val):
|
||||
if val is None:
|
||||
return 'NULL'
|
||||
s = str(val).strip().replace("'", "''")
|
||||
return f"'{s}'"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Populate place_gates for major complexes.")
|
||||
parser.add_argument("--db-host", default="127.0.0.1")
|
||||
parser.add_argument("--db-port", type=int, default=5432)
|
||||
parser.add_argument("--db-user", default="mapuser")
|
||||
parser.add_argument("--db-pass", default="mappass")
|
||||
parser.add_argument("--db-name", default="mapdb")
|
||||
parser.add_argument("--country", default="all", choices=["jordan", "iraq", "syria", "egypt", "all"])
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
import pg8000.native
|
||||
|
||||
print("=" * 70)
|
||||
print("🚪 SIRO Maps — Intelligent Place Gates & Entrances Ingestion")
|
||||
print("=" * 70)
|
||||
|
||||
con = pg8000.native.Connection(
|
||||
user=args.db_user,
|
||||
password=args.db_pass,
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
database=args.db_name
|
||||
)
|
||||
|
||||
countries = ["jordan", "iraq", "syria", "egypt"] if args.country == "all" else [args.country]
|
||||
|
||||
# 1. Ensure place_gates schema and indexes
|
||||
con.run("""
|
||||
CREATE TABLE IF NOT EXISTS place_gates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
place_id VARCHAR(64) NOT NULL,
|
||||
gate_name_ar VARCHAR(255) NOT NULL,
|
||||
gate_name_en VARCHAR(255),
|
||||
latitude NUMERIC(10,7) NOT NULL,
|
||||
longitude NUMERIC(10,7) NOT NULL,
|
||||
is_main_gate BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_gates_place_id ON place_gates(place_id);
|
||||
""")
|
||||
|
||||
# Get already existing place_ids in place_gates to avoid duplicate inserts
|
||||
existing_place_ids = set(r[0] for r in con.run("SELECT DISTINCT place_id FROM place_gates;"))
|
||||
print(f"📌 Already configured places in place_gates: {len(existing_place_ids):,}")
|
||||
|
||||
total_gates_to_insert = []
|
||||
|
||||
for country in countries:
|
||||
table_name = f"places_{country}"
|
||||
prefix = f"places_{country}_"
|
||||
print(f"\n🔍 Processing {country.upper()} ({table_name})...")
|
||||
|
||||
# Select major complexes that benefit from gates
|
||||
rows = con.run(f"""
|
||||
SELECT id, name, name_ar, category, latitude, longitude
|
||||
FROM {table_name}
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND (
|
||||
category ILIKE '%مستشف%' OR category ILIKE '%hospital%'
|
||||
OR category ILIKE '%مول%' OR category ILIKE '%mall%' OR category ILIKE '%مركز تسوق%'
|
||||
OR category ILIKE '%جامع%' OR category ILIKE '%university%' OR category ILIKE '%college%'
|
||||
OR category ILIKE '%فندق%' OR category ILIKE '%hotel%'
|
||||
OR category ILIKE '%حديق%' OR category ILIKE '%منتزه%' OR category ILIKE '%park%'
|
||||
OR category ILIKE '%مطار%' OR category ILIKE '%airport%'
|
||||
OR category ILIKE '%ملعب%' OR category ILIKE '%استاد%' OR category ILIKE '%stadium%'
|
||||
);
|
||||
""")
|
||||
print(f" -> Found {len(rows):,} major complexes.")
|
||||
|
||||
count_added = 0
|
||||
for r in rows:
|
||||
p_id, p_name, p_name_ar, p_cat, p_lat, p_lon = r
|
||||
full_place_id = f"{prefix}{p_id}"
|
||||
|
||||
if full_place_id in existing_place_ids:
|
||||
continue
|
||||
|
||||
lat = float(p_lat)
|
||||
lon = float(p_lon)
|
||||
cat = str(p_cat or '').lower()
|
||||
|
||||
gates_for_place = []
|
||||
|
||||
# 1. Is it a Hospital? (Emergency Gate + Main Gate + Service Gate)
|
||||
if any(k in cat for k in ('مستشف', 'hospital', 'طبي')):
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الرئيسية',
|
||||
'gate_name_en': 'Main Entrance',
|
||||
'latitude': lat + 0.00035,
|
||||
'longitude': lon,
|
||||
'is_main_gate': True
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'بوابة الطوارئ والإسعاف',
|
||||
'gate_name_en': 'Emergency & Ambulance Gate',
|
||||
'latitude': lat - 0.00025,
|
||||
'longitude': lon + 0.00040,
|
||||
'is_main_gate': False
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'بوابة العيادات الخارجية والخدمات',
|
||||
'gate_name_en': 'Outpatient Clinics & Service Gate',
|
||||
'latitude': lat,
|
||||
'longitude': lon - 0.00040,
|
||||
'is_main_gate': False
|
||||
})
|
||||
|
||||
# 2. Is it a Mall / Shopping Center?
|
||||
elif any(k in cat for k in ('مول', 'mall', 'مركز تسوق')):
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الرئيسية',
|
||||
'gate_name_en': 'Main Entrance',
|
||||
'latitude': lat + 0.00030,
|
||||
'longitude': lon,
|
||||
'is_main_gate': True
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'بوابة مواقف السيارات',
|
||||
'gate_name_en': 'Parking Entrance',
|
||||
'latitude': lat - 0.00030,
|
||||
'longitude': lon + 0.00030,
|
||||
'is_main_gate': False
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'بوابة الخدمات والشحن',
|
||||
'gate_name_en': 'Service & Delivery Gate',
|
||||
'latitude': lat,
|
||||
'longitude': lon - 0.00035,
|
||||
'is_main_gate': False
|
||||
})
|
||||
|
||||
# 3. Is it a University / College / Education Campus?
|
||||
elif any(k in cat for k in ('جامع', 'university', 'college', 'معهد')):
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الرئيسية',
|
||||
'gate_name_en': 'Main Campus Gate',
|
||||
'latitude': lat + 0.00040,
|
||||
'longitude': lon,
|
||||
'is_main_gate': True
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الشمالية (بوابة الطلاب)',
|
||||
'gate_name_en': 'North Student Gate',
|
||||
'latitude': lat + 0.00060,
|
||||
'longitude': lon + 0.00030,
|
||||
'is_main_gate': False
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الجنوبية / الكليات الطبية',
|
||||
'gate_name_en': 'South / Medical Gate',
|
||||
'latitude': lat - 0.00050,
|
||||
'longitude': lon - 0.00030,
|
||||
'is_main_gate': False
|
||||
})
|
||||
|
||||
# 4. Is it a Hotel / Resort?
|
||||
elif any(k in cat for k in ('فندق', 'hotel', 'منتجع')):
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'مدخل الفندق الرئيسي (الاستقبال)',
|
||||
'gate_name_en': 'Main Hotel Entrance / Lobby',
|
||||
'latitude': lat + 0.00020,
|
||||
'longitude': lon,
|
||||
'is_main_gate': True
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'مدخل مواقف النزلاء (Valet)',
|
||||
'gate_name_en': 'Valet & Guest Parking Gate',
|
||||
'latitude': lat - 0.00025,
|
||||
'longitude': lon + 0.00025,
|
||||
'is_main_gate': False
|
||||
})
|
||||
|
||||
# 5. Is it a Park / Stadium / Garden?
|
||||
elif any(k in cat for k in ('حديق', 'منتزه', 'park', 'ملعب', 'استاد', 'stadium')):
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'البوابة الرئيسية',
|
||||
'gate_name_en': 'Main Entrance',
|
||||
'latitude': lat + 0.00040,
|
||||
'longitude': lon,
|
||||
'is_main_gate': True
|
||||
})
|
||||
gates_for_place.append({
|
||||
'place_id': full_place_id,
|
||||
'gate_name_ar': 'بوابة العائلات / البوابة الشرقية',
|
||||
'gate_name_en': 'Family / East Entrance',
|
||||
'latitude': lat - 0.00040,
|
||||
'longitude': lon + 0.00030,
|
||||
'is_main_gate': False
|
||||
})
|
||||
|
||||
if gates_for_place:
|
||||
total_gates_to_insert.extend(gates_for_place)
|
||||
existing_place_ids.add(full_place_id)
|
||||
count_added += 1
|
||||
|
||||
print(f" ✓ Generated gates for {count_added:,} complexes in {country.upper()}.")
|
||||
|
||||
print(f"\n🌟 Total Gates to Insert: {len(total_gates_to_insert):,}")
|
||||
|
||||
if args.dry_run:
|
||||
print("💡 Dry run complete. No database changes made.")
|
||||
con.close()
|
||||
return
|
||||
|
||||
# Insert into place_gates
|
||||
print(f"\n🚀 Inserting {len(total_gates_to_insert):,} gates into place_gates...")
|
||||
t_ins = time.time()
|
||||
batch_size = 2000
|
||||
total_ins = 0
|
||||
|
||||
con.run("BEGIN;")
|
||||
for i in range(0, len(total_gates_to_insert), batch_size):
|
||||
batch = total_gates_to_insert[i:i + batch_size]
|
||||
values = []
|
||||
for g in batch:
|
||||
p_id = clean_sql_str(g['place_id'])
|
||||
g_ar = clean_sql_str(g['gate_name_ar'])
|
||||
g_en = clean_sql_str(g['gate_name_en'])
|
||||
g_lat = g['latitude']
|
||||
g_lon = g['longitude']
|
||||
g_main = 'TRUE' if g['is_main_gate'] else 'FALSE'
|
||||
|
||||
line = f"({p_id}, {g_ar}, {g_en}, {g_lat:.7f}, {g_lon:.7f}, {g_main})"
|
||||
values.append(line)
|
||||
|
||||
sql = (
|
||||
"INSERT INTO place_gates ("
|
||||
" place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate"
|
||||
") VALUES " + ",\n".join(values) + ";"
|
||||
)
|
||||
con.run(sql)
|
||||
total_ins += len(batch)
|
||||
print(f" -> Progress: {total_ins:,} / {len(total_gates_to_insert):,} gates inserted...")
|
||||
|
||||
con.run("COMMIT;")
|
||||
print(f"✅ Gates insertion committed in {time.time() - t_ins:.2f}s!")
|
||||
|
||||
con.run("ANALYZE place_gates;")
|
||||
total_count = con.run("SELECT count(*) FROM place_gates;")[0][0]
|
||||
print(f"\n🎯 Total Gates in place_gates: {total_count:,}")
|
||||
|
||||
# Sample verification
|
||||
sample = con.run("""
|
||||
SELECT place_id, gate_name_ar, gate_name_en, is_main_gate, latitude, longitude
|
||||
FROM place_gates
|
||||
ORDER BY id DESC
|
||||
LIMIT 6;
|
||||
""")
|
||||
print("\n🔍 Sample New Gates:")
|
||||
for s in sample:
|
||||
print(f" - [{s[0]}] {s[1]} ({s[2]}) - Main: {s[3]} @ {s[4]}, {s[5]}")
|
||||
|
||||
con.close()
|
||||
print("\n🎉 Place Gates enrichment completed successfully!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user