feat: add place gate population scripts, map style assets, automated cron sync tasks, and expand dashboard and landing applications

This commit is contained in:
Hamza-Ayed
2026-09-19 21:22:05 +03:00
parent 82b1045f9c
commit ce31d79ba6
76 changed files with 118969 additions and 115428 deletions
+3 -1
View File
@@ -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/
+2
View File
@@ -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) {}
+2 -1
View File
@@ -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 {
+20 -5
View File
@@ -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,
};
+2 -1
View File
@@ -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)
+21 -5
View File
@@ -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>
+10 -5
View File
@@ -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 {
+21 -3
View File
@@ -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' })
+9 -9
View File
@@ -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
View File
@@ -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);
+19 -1
View File
@@ -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)
+2 -7
View File
@@ -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 {
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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);
}
+12 -6
View File
@@ -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
View File
@@ -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 &copy; 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>
+2806 -330
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -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

+868
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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>;
}
+27
View File
@@ -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}
+9 -39
View File
@@ -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>;
}
+7 -85
View File
@@ -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;
+56
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
+6 -103
View File
@@ -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>;
}
+716 -91
View File
@@ -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;
}
+36 -287
View File
@@ -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>;
}
+38 -130
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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",
File diff suppressed because one or more lines are too long
+7
View File
@@ -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.');
+8 -3
View File
@@ -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
+47
View File
@@ -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
+8 -5
View File
@@ -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 && (
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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
```
+45
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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`. التغييرات محلية ولم تنشر إلى الإنتاج.
+8 -16
View File
@@ -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.
+1 -1
View File
@@ -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
+36
View File
@@ -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."
+34
View File
@@ -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."
+464
View File
@@ -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()
+523
View File
@@ -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()
+54
View File
@@ -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"
+313
View 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()
+305
View File
@@ -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()