2026-04-15-4

This commit is contained in:
Hamza-Ayed
2026-04-15 19:56:49 +03:00
parent 9cd1ac4c1d
commit 3d61362602
54 changed files with 12659 additions and 436 deletions
+9
View File
@@ -32,3 +32,12 @@ MAP_API_KEY=zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX
# Telegram Notifications
TELEGRAM_BOT_TOKEN=7618792580:AAE6YAdrgUdcuUu9g8kXveCb-hiO3ECOd1g
TELEGRAM_CHAT_ID=1766663126
# Firebase
FIREBASE_SERVICE_ACCOUNT_PATH=/secrets/firebase-service-account.json
# PayMob Integration (Test)
PAYMOB_API_KEY=ZXlKaGJHY2lPaUpJVXpVeE1pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SmpiR0Z6Y3lJNklrMWxjbU5vWVc1MElpd2ljSEp2Wm1sc1pWOXdheUk2T1Rjd09UWXdMQ0p1WVcxbElqb2lhVzVwZEdsaGJDSjkua0ZfRFlCU0Q2QkUtU25TS3FqSWRIbmh4NWxsUUlNQloySjlFZFFsS2NmNjJoeUpMeDRmY2NFOTZuYzVuQ25ocUdPaGJZUHdGRndfOVptU3FjR0pSdXc=
PAYMOB_HMAC_SECRET=7C9A0BEFC9DC11BF4C5EE05DE61C11F9
PAYMOB_INTEGRATION_ID=4556055
PAYMOB_IFRAME_ID=837992
+4 -1
View File
@@ -40,9 +40,12 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.28"
"typeorm": "^0.3.28",
"firebase-admin": "^12.1.0",
"nodemailer": "^6.9.13"
},
"devDependencies": {
"@types/nodemailer": "^6.4.14",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
+15 -2
View File
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
@@ -8,6 +8,10 @@ import { MapsModule } from './maps/maps.module';
import { GeocodingModule } from './geocoding/geocoding.module';
import { AuthModule } from './auth/auth.module';
import { ThrottlerModule } from '@nestjs/throttler';
import { UsageModule } from './usage/usage.module';
import { BillingModule } from './billing/billing.module';
import { MailModule } from './common/mail.module';
import { UsageInterceptor } from './usage/usage.interceptor';
@Module({
imports: [
@@ -33,7 +37,16 @@ import { ThrottlerModule } from '@nestjs/throttler';
TelemetryModule,
MapsModule,
GeocodingModule,
UsageModule,
BillingModule,
MailModule,
],
controllers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: UsageInterceptor,
},
],
providers: [],
})
export class AppModule {}
+4 -2
View File
@@ -6,6 +6,8 @@ import { ApiKey } from './entities/api-key.entity';
import { RedisModule } from '../common/redis.module';
import { ConfigService } from '@nestjs/config';
import { TenantController } from './tenant.controller';
import { FirebaseAdminService } from './firebase-admin.service';
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
@Global()
@Module({
@@ -14,8 +16,8 @@ import { TenantController } from './tenant.controller';
RedisModule,
],
controllers: [TenantController],
providers: [AuthService],
exports: [AuthService],
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
})
export class AuthModule implements OnModuleInit {
constructor(
+49 -2
View File
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import { ApiKey } from './entities/api-key.entity';
import { Tenant } from './entities/tenant.entity';
import { Tenant, TenantPlan } from './entities/tenant.entity';
import { RedisService } from '../common/redis.service';
@Injectable()
@@ -16,7 +16,7 @@ export class AuthService {
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
private readonly redisService: RedisService,
) {}
) { }
/**
* Validate an API key and check its restrictions (Origin/Referer).
@@ -164,4 +164,51 @@ export class AuthService {
if (!tenant) throw new NotFoundException('No tenants found in system');
return tenant;
}
/**
* Find a tenant by Firebase UID or create one if it doesn't exist.
* Supports linking Google accounts to existing email-based tenants.
*/
async findOrCreateByFirebaseUid(uid: string, email: string, name: string, photoUrl?: string): Promise<Tenant> {
// 1. Try finding by Firebase UID
let tenant = await this.tenantRepository.findOne({ where: { firebaseUid: uid } });
if (!tenant) {
// 2. Try finding by email for account linking
tenant = await this.tenantRepository.findOne({ where: { email } });
if (tenant) {
this.logger.log(`Linking existing tenant ${email} to Firebase UID: ${uid}`);
tenant.firebaseUid = uid;
if (photoUrl) tenant.photoUrl = photoUrl;
tenant = await this.tenantRepository.save(tenant);
} else {
// 3. Create new if neither found
this.logger.log(`Creating new tenant for Firebase user: ${email} (${uid})`);
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
tenant = await this.tenantRepository.save({
firebaseUid: uid,
email,
name,
photoUrl,
plan: isAdmin ? TenantPlan.ENTERPRISE : TenantPlan.FREE,
isActive: true,
});
}
} else {
// Auto-upgrade admins if they exist but are on lower plan
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
if (isAdmin && tenant.plan !== TenantPlan.ENTERPRISE) {
tenant.plan = TenantPlan.ENTERPRISE;
await this.tenantRepository.save(tenant);
}
// Update info if changed
if (tenant.photoUrl !== photoUrl || tenant.name !== name) {
await this.tenantRepository.update(tenant.id, { photoUrl, name });
}
}
return tenant;
}
}
+19 -1
View File
@@ -3,10 +3,15 @@ import { ApiKey } from './api-key.entity';
export enum TenantPlan {
FREE = 'FREE',
PREMIUM = 'PREMIUM',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}
export enum TenantRole {
USER = 'USER',
ADMIN = 'ADMIN',
}
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
@@ -25,6 +30,19 @@ export class Tenant {
})
plan: TenantPlan;
@Column({ nullable: true, unique: true })
firebaseUid: string;
@Column({
type: 'enum',
enum: TenantRole,
default: TenantRole.USER,
})
role: TenantRole;
@Column({ nullable: true })
photoUrl: string;
@Column({ default: true })
isActive: boolean;
@@ -0,0 +1,53 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class FirebaseAdminService implements OnModuleInit {
private readonly logger = new Logger(FirebaseAdminService.name);
private firebaseApp: admin.app.App;
constructor(private configService: ConfigService) {}
onModuleInit() {
const keyPath = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT_PATH');
if (!keyPath) {
this.logger.error('FIREBASE_SERVICE_ACCOUNT_PATH is not defined in environment variables');
return;
}
try {
// Resolve path relative to workspace root if it's not absolute
const absolutePath = path.isAbsolute(keyPath)
? keyPath
: path.resolve(process.cwd(), keyPath);
if (!fs.existsSync(absolutePath)) {
this.logger.error(`Firebase service account key not found at: ${absolutePath}`);
return;
}
const serviceAccount = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
this.logger.log('✅ Firebase Admin SDK initialized successfully');
} catch (error) {
this.logger.error(`Failed to initialize Firebase Admin SDK: ${error.message}`);
}
}
async verifyIdToken(token: string): Promise<admin.auth.DecodedIdToken> {
try {
return await admin.auth().verifyIdToken(token);
} catch (error) {
this.logger.error(`Token verification failed: ${error.message}`);
throw error;
}
}
}
@@ -0,0 +1,56 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { FirebaseAdminService } from '../firebase-admin.service';
import { AuthService } from '../auth.service';
@Injectable()
export class FirebaseAuthGuard implements CanActivate {
private readonly logger = new Logger(FirebaseAuthGuard.name);
constructor(
private firebaseAdminService: FirebaseAdminService,
private authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid Authorization header');
}
const token = authHeader.split('Bearer ')[1];
try {
// 1. Verify Firebase ID Token
const decodedToken = await this.firebaseAdminService.verifyIdToken(token);
// 2. Find or create Tenant based on Firebase info
const tenant = await this.authService.findOrCreateByFirebaseUid(
decodedToken.uid,
decodedToken.email || '',
decodedToken.name || decodedToken.email?.split('@')[0] || 'Unknown User',
decodedToken.picture,
);
if (!tenant.isActive) {
throw new UnauthorizedException('Tenant account is disabled');
}
// 3. Attach tenant to request for controllers
request['tenant'] = tenant;
request['user'] = decodedToken;
return true;
} catch (error) {
this.logger.error(`Firebase Auth failed: ${error.message}`);
throw new UnauthorizedException(error.message || 'Authentication failed');
}
}
}
+18 -13
View File
@@ -1,25 +1,23 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateKeyDto } from './dto/management/create-key.dto';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
@ApiTags('auth')
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Controller('auth/management')
export class TenantController {
constructor(private readonly authService: AuthService) {}
@Get('keys/:tenantId')
@ApiOperation({ summary: 'Get all API keys for a tenant' })
async getKeys(@Param('tenantId') tenantId: string) {
return this.authService.getApiKeys(tenantId);
}
@Post('keys/:tenantId')
@Post('keys')
@ApiOperation({ summary: 'Create a new API key' })
async createKey(
@Param('tenantId') tenantId: string,
@Req() req: any,
@Body() dto: CreateKeyDto
) {
const tenantId = req.tenant.id;
return this.authService.createApiKey(
tenantId,
dto.name,
@@ -28,9 +26,16 @@ export class TenantController {
);
}
@Get('keys')
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
async getKeys(@Req() req: any) {
const tenantId = req.tenant.id;
return this.authService.getApiKeys(tenantId);
}
@Get('me')
@ApiOperation({ summary: 'Identify the default tenant (Demo Only)' })
async getMe() {
return this.authService.getDefaultTenant();
@ApiOperation({ summary: 'Get current authenticated tenant info' })
async getMe(@Req() req: any) {
return req.tenant;
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Controller, Get, Post, Body, Query, UseGuards, Req, Logger, BadRequestException } from '@nestjs/common';
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 { PaymentProvider, PaymentStatus } from './entities/transaction.entity';
@ApiTags('billing')
@Controller('billing')
export class BillingController {
private readonly logger = new Logger(BillingController.name);
constructor(
private billingService: BillingService,
private paymobProvider: PayMobProvider,
private binanceProvider: BinanceProvider,
) {}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Get('subscription')
@ApiOperation({ summary: 'Get current subscription details' })
async getSubscription(@Req() req: any) {
return this.billingService.getSubscription(req.tenant.id);
}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Post('checkout')
@ApiOperation({ summary: 'Initialize a checkout session' })
async checkout(@Req() req: any, @Body() body: { plan: string; provider: PaymentProvider }) {
const tenantId = req.tenant.id;
const plan = body.plan;
const amount = plan === 'PRO' ? 40 : 0; // Price logic
if (body.provider === PaymentProvider.PAYMOB) {
const { paymentKey, orderId } = await this.paymobProvider.createPaymentKey(tenantId, amount, plan);
// Iframe URL (Using the new provided Iframe ID)
const iframeId = this.billingService.getIframeId();
const checkoutUrl = `https://accept.paymob.com/api/acceptance/iframes/${iframeId}?payment_token=${paymentKey}`;
return { checkoutUrl, orderId };
}
if (body.provider === PaymentProvider.BINANCE) {
return this.binanceProvider.createOrder(tenantId, amount, plan);
}
throw new BadRequestException('Invalid payment provider');
}
/**
* PayMob Transaction Processed Webhook
* This is called by PayMob when a transaction is attempted
*/
@Post('webhooks/paymob')
@ApiOperation({ summary: 'PayMob Payment Webhook' })
async handlePaymobWebhook(@Body() body: any, @Query('hmac') hmac: string) {
this.logger.log(`📥 PayMob Webhook Received: Transaction ID ${body.obj?.id}`);
// 1. Verify HMAC
if (!this.paymobProvider.verifyHmac(body.obj, hmac)) {
this.logger.error('❌ PayMob HMAC Verification Failed');
throw new BadRequestException('Invalid signature');
}
// 2. Process payment if successful
if (body.obj.success === true) {
// Extract metadata from extra_description (format: tenantId|plan)
const extraDesc = body.obj.order?.shipping_data?.extra_description || "";
const [tenantId, plan] = extraDesc.split('|');
if (!tenantId) {
this.logger.error(`❌ PayMob Webhook failed: No tenantId in metadata. Raw: ${extraDesc}`);
return { status: 'error', message: 'No tenantId found' };
}
await this.billingService.processSuccessfulPayment(
body.obj.id.toString(),
PaymentProvider.PAYMOB,
body.obj.amount_cents / 100,
{
tenantId,
plan: plan || 'PRO'
}
);
}
return { status: 'success' };
}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Get('invoices')
@ApiOperation({ summary: 'Get payment history' })
async getInvoices(@Req() req: any) {
return this.billingService.getTransactionHistory(req.tenant.id);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Subscription } from './entities/subscription.entity';
import { Transaction } from './entities/transaction.entity';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { PayMobProvider } from './providers/paymob.provider';
import { BinanceProvider } from './providers/binance.provider';
import { Tenant } from '../auth/entities/tenant.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription, Transaction, Tenant]),
],
providers: [BillingService, PayMobProvider, BinanceProvider],
controllers: [BillingController],
exports: [BillingService],
})
export class BillingModule {}
+142
View File
@@ -0,0 +1,142 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription, SubscriptionStatus } from './entities/subscription.entity';
import { Transaction, PaymentStatus, PaymentProvider } from './entities/transaction.entity';
import { Tenant, TenantPlan } from '../auth/entities/tenant.entity';
import { MailService } from '../common/mail.service';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
@InjectRepository(Subscription)
private subscriptionRepository: Repository<Subscription>,
@InjectRepository(Transaction)
private transactionRepository: Repository<Transaction>,
@InjectRepository(Tenant)
private tenantRepository: Repository<Tenant>,
private configService: ConfigService,
private mailService: MailService,
) {}
/**
* Get configured PayMob Iframe ID
*/
getIframeId(): string {
return this.configService.get('PAYMOB_IFRAME_ID', '837992');
}
/**
* Get current subscription for a tenant
*/
async getSubscription(tenantId: string): Promise<Subscription> {
let sub = await this.subscriptionRepository.findOne({ where: { tenantId } });
// Auto-initialize if not exists
if (!sub) {
sub = await this.subscriptionRepository.save({
tenantId,
plan: 'FREE',
monthlyRequestLimit: 8000,
status: SubscriptionStatus.ACTIVE,
});
}
return sub;
}
/**
* Handle successful payment from any provider
*/
async processSuccessfulPayment(
externalTxId: string,
provider: PaymentProvider,
amount: number,
metadata: any
) {
// 1. Find or create transaction
let txn = await this.transactionRepository.findOne({ where: { externalTransactionId: externalTxId } });
if (txn && txn.status === PaymentStatus.SUCCESS) {
this.logger.warn(`Transaction ${externalTxId} already processed.`);
return;
}
if (!txn) {
// This might happen if webhook arrives before front-end redirect
// Logic to resolve tenantId from metadata should be here
const tenantId = metadata.tenantId;
if (!tenantId) throw new BadRequestException('No tenantId found in payment metadata');
txn = await this.transactionRepository.save({
tenantId,
externalTransactionId: externalTxId,
amount,
provider,
status: PaymentStatus.SUCCESS,
metadata,
});
} else {
txn.status = PaymentStatus.SUCCESS;
txn.metadata = { ...txn.metadata, ...metadata };
await this.transactionRepository.save(txn);
}
// 2. Upgrade the subscription and tenant plan
const tenantId = txn.tenantId;
const plan = metadata.plan || 'PRO'; // Default to PRO for paid txns
await this.upgradeTenantPlan(tenantId, plan as TenantPlan);
// 3. Send Invoice Email
try {
const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } });
if (tenant && tenant.email) {
await this.mailService.sendInvoiceEmail(tenant.email, {
tenantName: tenant.name,
plan: plan,
amount: `${amount} EGP`,
transactionId: externalTxId,
});
}
} catch (err) {
this.logger.error(`Failed to send invoice email for tx ${externalTxId}: ${err.message}`);
}
this.logger.log(`✅ Tenant ${tenantId} upgraded to ${plan} after payment ${externalTxId}`);
}
private async upgradeTenantPlan(tenantId: string, plan: TenantPlan) {
// Update Tenant
await this.tenantRepository.update(tenantId, { plan });
// Update Subscription
const limits = {
[TenantPlan.FREE]: 8000,
[TenantPlan.PRO]: 50000,
[TenantPlan.ENTERPRISE]: 1000000,
};
const sub = await this.getSubscription(tenantId);
sub.plan = plan;
sub.monthlyRequestLimit = limits[plan] || 8000;
sub.status = SubscriptionStatus.ACTIVE;
sub.currentPeriodStart = new Date();
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
sub.currentPeriodEnd = nextMonth;
await this.subscriptionRepository.save(sub);
}
async getTransactionHistory(tenantId: string): Promise<Transaction[]> {
return this.transactionRepository.find({
where: { tenantId },
order: { createdAt: 'DESC' },
});
}
}
@@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
Index,
} from 'typeorm';
import { Tenant } from '../../auth/entities/tenant.entity';
export enum SubscriptionStatus {
ACTIVE = 'ACTIVE',
PAST_DUE = 'PAST_DUE',
CANCELED = 'CANCELED',
TRIALING = 'TRIALING',
}
@Entity('subscriptions')
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
tenantId: string;
@Column({ default: 'FREE' })
plan: string;
@Column({
type: 'enum',
enum: SubscriptionStatus,
default: SubscriptionStatus.ACTIVE,
})
status: SubscriptionStatus;
@Column({ type: 'int', default: 8000 })
monthlyRequestLimit: number;
@Column({ type: 'timestamp', nullable: true })
currentPeriodStart: Date;
@Column({ type: 'timestamp', nullable: true })
currentPeriodEnd: Date;
@Column({ type: 'timestamp', nullable: true })
canceledAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,62 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum PaymentStatus {
PENDING = 'PENDING',
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
REFUNDED = 'REFUNDED',
}
export enum PaymentProvider {
PAYMOB = 'PAYMOB',
BINANCE = 'BINANCE',
MANUAL = 'MANUAL',
}
@Entity('transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
tenantId: string;
@Column({ nullable: true })
subscriptionId: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amount: number;
@Column({ default: 'USD' })
currency: string;
@Column({
type: 'enum',
enum: PaymentProvider,
})
provider: PaymentProvider;
@Index()
@Column({ nullable: true })
externalTransactionId: string;
@Column({
type: 'enum',
enum: PaymentStatus,
default: PaymentStatus.PENDING,
})
status: PaymentStatus;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,33 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class BinanceProvider {
private readonly logger = new Logger(BinanceProvider.name);
constructor(private configService: ConfigService) {}
/**
* Mock Binance Pay Order Creation
* Will lead to a success/fail redirect for testing
*/
async createOrder(tenantId: string, amount: number, plan: string) {
this.logger.log(`[Mock] Creating Binance Pay order for ${tenantId} - ${plan}`);
// In production, this calls https://bpay.binanceapi.com/binancepay/openapi/v2/order
// and returns a checkoutUrl.
return {
checkoutUrl: `https://map-dashbord.intaleqapp.com/api/billing/mock-binance-success?tenantId=${tenantId}&plan=${plan}`,
prepayId: `mock_${Date.now()}`
};
}
/**
* Mock Webhook Signature Verification
*/
verifySignature(payload: any, signature: string): boolean {
// In production, uses HMAC-SHA512 with Binance Secret Key
return true;
}
}
@@ -0,0 +1,147 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as crypto from 'crypto';
@Injectable()
export class PayMobProvider {
private readonly logger = new Logger(PayMobProvider.name);
private readonly baseUrl = 'https://accept.paymob.com/api';
constructor(private configService: ConfigService) {}
/**
* Step 1 & 2: Authenticate and Create Order
*/
async createPaymentKey(tenantId: string, amount: number, plan: string): Promise<{ paymentKey: string; orderId: string }> {
try {
// 1. Authentication Request
const authRes = await axios.post(`${this.baseUrl}/auth/tokens`, {
api_key: this.configService.get('PAYMOB_API_KEY'),
});
const authToken = authRes.data.token;
// 2. Order Registration
const orderRes = await axios.post(`${this.baseUrl}/ecommerce/orders`, {
auth_token: authToken,
delivery_needed: "false",
amount_cents: amount * 100, // PayMob uses cents
currency: "EGP", // Or USD based on integration
items: [{
name: `${plan} Subscription`,
amount_cents: amount * 100,
description: `Intaleq Maps ${plan} Plan`
}],
shipping_data: {
// Mandatory dummy data + Metadata for identification
first_name: "Intaleq",
last_name: "User",
email: "user@intaleq.com",
phone_number: "+201000000000",
extra_description: `${tenantId}|${plan}` // Used to identify tenant in webhook
}
});
const orderId = orderRes.data.id;
// 3. Payment Key Generation
const keyRes = await axios.post(`${this.baseUrl}/acceptance/payment_keys`, {
auth_token: authToken,
amount_cents: amount * 100,
expiration: 3600,
order_id: orderId,
billing_data: {
apartment: "NA",
email: "user@intaleq.com",
floor: "NA",
first_name: "Intaleq",
street: "NA",
building: "NA",
phone_number: "+201000000000",
shipping_method: "NA",
postal_code: "NA",
city: "NA",
country: "NA",
last_name: "User",
state: "NA"
},
currency: "EGP",
integration_id: this.configService.get('PAYMOB_INTEGRATION_ID') || 4556055,
lock_order_when_paid: "false"
});
return {
paymentKey: keyRes.data.token,
orderId: orderId.toString()
};
} catch (error) {
this.logger.error(`PayMob Payment Key failed: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`);
throw new BadRequestException('Failed to initialize PayMob payment');
}
}
/**
* Verify HMAC signature from PayMob Webhook
*/
verifyHmac(payload: any, hmac: string): boolean {
const secret = this.configService.get('PAYMOB_HMAC_SECRET');
if (!secret) return false;
// Concat fields in specific order as per PayMob docs
const {
amount_cents,
created_at,
currency,
error_occured,
has_parent_transaction,
id,
integration_id,
is_3d_secure,
is_auth,
is_capture,
is_refunded,
is_standalone_payment,
is_voided,
order,
owner,
pending,
source_data_pan,
source_data_sub_type,
source_data_type,
success
} = payload;
const source_pan = source_data_pan || "";
const source_sub_type = source_data_sub_type || "";
const source_type = source_data_type || "";
const data = [
amount_cents,
created_at,
currency,
error_occured,
has_parent_transaction,
id,
integration_id,
is_3d_secure,
is_auth,
is_capture,
is_refunded,
is_standalone_payment,
is_voided,
order.id, // PayMob sends order as object
owner,
pending,
source_pan,
source_sub_type,
source_type,
success
].join("");
const hash = crypto
.createHmac('sha512', secret)
.update(data)
.digest('hex');
return hash === hmac;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';
@Global()
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+78
View File
@@ -0,0 +1,78 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) {
const host = this.configService.get<string>('SMTP_HOST');
const port = this.configService.get<number>('SMTP_PORT') || 587;
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
if (host && user && pass) {
this.transporter = nodemailer.createTransport({
host,
port,
secure: port === 465, // true for 465, false for other ports
auth: {
user,
pass,
},
});
this.logger.log(`📧 MailService initialized with host: ${host}`);
} else {
this.logger.warn('⚠️ SMTP settings not fully configured. MailService will run in mock mode.');
}
}
async sendInvoiceEmail(to: string, data: {
tenantName: string,
plan: string,
amount: string,
transactionId: string
}): Promise<boolean> {
const from = this.configService.get<string>('SMTP_FROM') || 'support@intaleqapp.com';
const htmlContent = `
<div style="font-family: sans-serif; max-width: 600px; margin: auto; border: 1px solid #eee; padding: 20px; border-radius: 10px;">
<h2 style="color: #2563eb;">Intaleq Maps - Payment Successful</h2>
<p>Hello <b>${data.tenantName}</b>,</p>
<p>Thank you for subscribing to our <b>${data.plan}</b> plan. Your payment has been processed successfully.</p>
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 5px 0;"><b>Plan:</b> ${data.plan}</p>
<p style="margin: 5px 0;"><b>Amount:</b> ${data.amount}</p>
<p style="margin: 5px 0;"><b>Transaction ID:</b> ${data.transactionId}</p>
<p style="margin: 5px 0;"><b>Date:</b> ${new Date().toLocaleDateString()}</p>
</div>
<p>You can now access all your premium features from your dashboard.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #64748b;">If you have any questions, contact us at ${from}</p>
</div>
`;
if (!this.transporter) {
this.logger.warn(`[Mock Email] To: ${to}, Plan: ${data.plan}, Amount: ${data.amount}`);
return true;
}
try {
await this.transporter.sendMail({
from: `"Intaleq Support" <${from}>`,
to,
subject: `Invoice: ${data.plan} Subscription - Intaleq Maps`,
html: htmlContent,
});
this.logger.log(`✅ Invoice email sent to ${to}`);
return true;
} catch (error) {
this.logger.error(`❌ Failed to send email: ${error.message}`);
return false;
}
}
}
+7
View File
@@ -12,6 +12,13 @@ export class RedisService implements OnModuleDestroy {
@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType,
) {}
/**
* Get the raw Redis client for atomic operations (incr, etc.)
*/
getClient(): RedisClientType {
return this.redisClient;
}
/**
* Set a value in Redis with optional TTL (Time To Live).
* حفظ قيمة في ذاكرة Redis مع وقت انتهاء اختياري
@@ -0,0 +1,43 @@
import { Entity, Column, Index } from 'typeorm';
import { BasePlace } from './base-place.entity';
export enum CandidateStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED'
}
export enum CountryCode {
JORDAN = 'JORDAN',
SYRIA = 'SYRIA',
EGYPT = 'EGYPT'
}
@Entity('map_candidates')
export class MapCandidate extends BasePlace {
@Column({
type: 'enum',
enum: CandidateStatus,
default: CandidateStatus.PENDING
})
@Index()
status: CandidateStatus;
@Column({ nullable: true })
@Index()
submittedBy: string; // Email or User UID
@Column({ type: 'text', nullable: true })
rejectionReason: string;
@Column({
type: 'enum',
enum: CountryCode,
default: CountryCode.JORDAN
})
@Index()
country: CountryCode;
@Column({ type: 'jsonb', nullable: true })
metadata: any; // For photos, extra contact info, etc.
}
+9 -4
View File
@@ -14,6 +14,9 @@ import { JordanResearchService } from './jordan-research.service';
import { AdministrativeLinkingService } from './administrative-linking.service';
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
import { MapCandidate } from './entities/map-candidate.entity';
import { MapRefinementService } from './map-refinement.service';
import { MapRefinementController } from './map-refinement.controller';
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
@@ -27,7 +30,8 @@ import * as redisStore from 'cache-manager-redis-store';
OsmPointWithArea,
AdminBoundary,
NeighborhoodPoint,
NeighborhoodPolygon
NeighborhoodPolygon,
MapCandidate
]),
CacheModule.register({
store: redisStore,
@@ -36,14 +40,15 @@ import * as redisStore from 'cache-manager-redis-store';
ttl: 3600, // 1 hour in seconds for version 1-2, or ms for v3
}),
],
controllers: [GeocodingController],
controllers: [GeocodingController, MapRefinementController],
providers: [
GeocodingService,
GeocodingInitService,
AdminBoundariesService,
JordanResearchService,
AdministrativeLinkingService
AdministrativeLinkingService,
MapRefinementService
],
exports: [GeocodingService],
exports: [GeocodingService, MapRefinementService],
})
export class GeocodingModule {}
@@ -0,0 +1,44 @@
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 { CandidateStatus } from './entities/map-candidate.entity';
@ApiTags('map-refinement')
@Controller('map-refinement')
export class MapRefinementController {
constructor(private readonly refinementService: MapRefinementService) {}
@Post('suggest')
@ApiOperation({ summary: 'Suggest a new location for the map (Client App)' })
@ApiHeader({ name: 'x-api-key', description: 'API Key for usage tracking' })
async suggest(@Req() req: any, @Body() dto: any) {
// Both Firebase Auth and API Key are allowed here
const userId = req.user?.uid || req.tenant?.id || 'anonymous';
return this.refinementService.suggestPlace(dto, userId);
}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Get('candidates')
@ApiOperation({ summary: 'List suggested places for review (Dashboard)' })
async list(@Query('status') status?: CandidateStatus) {
return this.refinementService.getCandidates(status);
}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Patch('candidates/:id/approve')
@ApiOperation({ summary: 'Approve a candidate and move to production (Dashboard)' })
async approve(@Param('id') id: string) {
return this.refinementService.approveCandidate(parseInt(id, 10));
}
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Patch('candidates/:id/reject')
@ApiOperation({ summary: 'Reject a candidate (Dashboard)' })
async reject(@Param('id') id: string, @Body() body: { reason: string }) {
return this.refinementService.rejectCandidate(parseInt(id, 10), body.reason);
}
}
@@ -0,0 +1,88 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MapCandidate, CandidateStatus, CountryCode } from './entities/map-candidate.entity';
import { PlaceJordan } from './entities/place-jordan.entity';
import { PlaceSyria } from './entities/place-syria.entity';
import { PlaceEgypt } from './entities/place-egypt.entity';
@Injectable()
export class MapRefinementService {
private readonly logger = new Logger(MapRefinementService.name);
constructor(
@InjectRepository(MapCandidate)
private candidateRepository: Repository<MapCandidate>,
@InjectRepository(PlaceJordan)
private jordanRepository: Repository<PlaceJordan>,
@InjectRepository(PlaceSyria)
private syriaRepository: Repository<PlaceSyria>,
@InjectRepository(PlaceEgypt)
private egyptRepository: Repository<PlaceEgypt>,
) {}
async suggestPlace(dto: any, submittedBy: string): Promise<MapCandidate> {
const candidate = this.candidateRepository.create({
...dto,
submittedBy,
status: CandidateStatus.PENDING,
location: {
type: 'Point',
coordinates: [parseFloat(dto.longitude), parseFloat(dto.latitude)],
},
});
return this.candidateRepository.save(candidate) as unknown as Promise<MapCandidate>;
}
async getCandidates(status?: CandidateStatus): Promise<MapCandidate[]> {
return this.candidateRepository.find({
where: status ? { status } : {},
order: { created_at: 'DESC' },
});
}
async approveCandidate(id: number): Promise<any> {
const candidate = await this.candidateRepository.findOne({ where: { id } });
if (!candidate) throw new NotFoundException('Candidate not found');
// Transfer to production table based on country
const placeData = {
latitude: candidate.latitude,
longitude: candidate.longitude,
name: candidate.name,
name_ar: candidate.name_ar,
name_en: candidate.name_en,
category: candidate.category,
address: candidate.address,
description: candidate.description,
location: candidate.location, // GeoJSON
source: `suggested_by_${candidate.submittedBy}`,
};
let repo: Repository<any>;
switch (candidate.country) {
case CountryCode.SYRIA: repo = this.syriaRepository; break;
case CountryCode.EGYPT: repo = this.egyptRepository; break;
default: repo = this.jordanRepository;
}
const approvedPlace = repo.create(placeData);
await repo.save(approvedPlace);
// Update candidate status
candidate.status = CandidateStatus.APPROVED;
await this.candidateRepository.save(candidate);
this.logger.log(`✅ Approved candidate ${id}: ${candidate.name} moved to production.`);
return { success: true, place: approvedPlace };
}
async rejectCandidate(id: number, reason: string): Promise<MapCandidate> {
const candidate = await this.candidateRepository.findOne({ where: { id } });
if (!candidate) throw new NotFoundException('Candidate not found');
candidate.status = CandidateStatus.REJECTED;
candidate.rejectionReason = reason;
return this.candidateRepository.save(candidate) as unknown as Promise<MapCandidate>;
}
}
+2 -4
View File
@@ -1,7 +1,8 @@
import { NestFactory, Reflector } from '@nestjs/core';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import * as express from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -10,9 +11,6 @@ async function bootstrap() {
});
// 1. Modern Security Headers (Prevention of XSS, Clickjacking, etc.)
app.use(helmet());
// 2. Enable CORS with specific defaults (can be refined via Tenant settings)
app.enableCors();
// 3. Global API prefix
+42
View File
@@ -0,0 +1,42 @@
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';
@ApiTags('usage')
@ApiBearerAuth()
@UseGuards(FirebaseAuthGuard)
@Controller('usage')
export class UsageController {
constructor(private usageService: UsageService) {}
@Get('summary')
@ApiOperation({ summary: 'Get current month usage summary' })
async getSummary(@Req() req: any) {
const tenant = req.tenant;
const summary = await this.usageService.getUsageSummary(tenant.id);
// Limits
const limits = {
FREE: 8000,
PRO: 50000,
ENTERPRISE: 1000000,
};
const limit = limits[tenant.plan] || 8000;
return {
...summary,
limit,
percentage: Math.min(((summary.monthlyUsage / limit) * 100), 100).toFixed(1),
plan: tenant.plan,
};
}
@Get('history')
@ApiOperation({ summary: 'Get daily request volume history' })
async getHistory(@Req() req: any, @Query('days') days: number = 30) {
const tenantId = req.tenant.id;
return this.usageService.getUsageHistory(tenantId, days);
}
}
+43
View File
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('usage_logs')
export class UsageLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column()
tenantId: string;
@Index()
@Column()
apiKeyId: string;
@Column()
endpoint: string; // e.g., '/maps/style.json', '/geocoding/search'
@Column()
method: string; // 'GET', 'POST', etc.
@Column({ type: 'int', default: 200 })
statusCode: number;
@Column({ type: 'int', default: 0 })
responseTimeMs: number;
@Column({ nullable: true })
userAgent: string;
@Column({ nullable: true })
ipAddress: string;
@Index()
@CreateDateColumn()
createdAt: Date;
}
+74
View File
@@ -0,0 +1,74 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { UsageService } from './usage.service';
import { TenantPlan } from '../auth/entities/tenant.entity';
// Quota Limits per Plan
const QUOTA_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 8000,
[TenantPlan.PRO]: 50000,
[TenantPlan.ENTERPRISE]: 1000000,
};
@Injectable()
export class UsageInterceptor implements NestInterceptor {
private readonly logger = new Logger(UsageInterceptor.name);
constructor(private usageService: UsageService) { }
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const tenant = request.tenant;
const apiKey = request.apiKey; // Set by ApiKeyGuard
// 1. Quota Enforcement (Skip for Management requests / internal)
if (tenant && apiKey && !request.url.includes('/auth/management')) {
this.logger.debug(`📊 Monitoring request for Tenant: ${tenant.id}`);
const plan = tenant.plan || TenantPlan.FREE;
const limit = QUOTA_LIMITS[plan];
const { allowed, used } = await this.usageService.checkQuota(tenant.id, limit);
if (!allowed) {
throw new HttpException({
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Monthly API usage quota exceeded',
used,
limit,
upgrade_url: 'https://map-dashbord.intaleqapp.com/#billing'
}, HttpStatus.TOO_MANY_REQUESTS);
}
}
const startTime = Date.now();
return next.handle().pipe(
tap(() => {
// 2. Async Recording
if (tenant && apiKey) {
const response = context.switchToHttp().getResponse();
const responseTimeMs = Date.now() - startTime;
this.logger.log(`📈 Usage Recorded: ${request.method} ${request.url.split('?')[0]} for Tenant ${tenant.id}`);
this.usageService.recordRequest({
tenantId: tenant.id,
apiKeyId: apiKey.id,
endpoint: request.url.split('?')[0],
method: request.method,
statusCode: response.statusCode,
responseTimeMs,
userAgent: request.headers['user-agent'],
ipAddress: request.ip || request.headers['x-forwarded-for'],
});
}
}),
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsageLog } from './usage.entity';
import { UsageService } from './usage.service';
import { UsageController } from './usage.controller';
@Module({
imports: [
TypeOrmModule.forFeature([UsageLog]),
],
providers: [UsageService],
controllers: [UsageController],
exports: [UsageService],
})
export class UsageModule {}
+115
View File
@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsageLog } from './usage.entity';
import { RedisService } from '../common/redis.service';
@Injectable()
export class UsageService {
private readonly logger = new Logger(UsageService.name);
constructor(
@InjectRepository(UsageLog)
private usageRepository: Repository<UsageLog>,
private redisService: RedisService,
) {}
/**
* Record a request in both PostgreSQL (for history) and Redis (for instant quota)
*/
async recordRequest(data: {
tenantId: string;
apiKeyId: string;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
userAgent?: string;
ipAddress?: string;
}) {
// 1. Log to PostgreSQL (Async)
this.usageRepository.save(data).catch((err) => {
this.logger.error(`Failed to save usage log to DB: ${err.message}`);
});
// 2. Increment Redis Counter for the current month
const monthKey = this.getMonthlyKey(data.tenantId);
try {
await this.redisService.getClient().incr(monthKey);
} catch (err) {
this.logger.error(`Failed to increment Redis usage counter: ${err.message}`);
}
}
/**
* Get current month usage from Redis
*/
async getMonthlyUsage(tenantId: string): Promise<number> {
const key = this.getMonthlyKey(tenantId);
const val = await this.redisService.getClient().get(key);
return val ? parseInt(val, 10) : 0;
}
/**
* Check if a tenant has exceeded their monthly quota
*/
async checkQuota(tenantId: string, limit: number): Promise<{ allowed: boolean; used: number }> {
const used = await this.getMonthlyUsage(tenantId);
return {
allowed: used < limit,
used,
};
}
/**
* Get usage history for charts
*/
async getUsageHistory(tenantId: string, days: number = 30) {
return this.usageRepository
.createQueryBuilder('usage')
.select("DATE_TRUNC('day', usage.createdAt)", 'date')
.addSelect('COUNT(*)', 'count')
.where('usage.tenantId = :tenantId', { tenantId })
.andWhere("usage.createdAt >= NOW() - (:days || ' days')::INTERVAL", { days: days.toString() })
.groupBy("DATE_TRUNC('day', usage.createdAt)")
.orderBy('date', 'ASC')
.getRawMany();
}
/**
* Get real-time summary for the dashboard
*/
async getUsageSummary(tenantId: string) {
const monthlyUsage = await this.getMonthlyUsage(tenantId);
// Get daily stats and performance metrics
const stats = await this.usageRepository
.createQueryBuilder('usage')
.select('COUNT(*)', 'totalToday')
.addSelect('AVG(usage.responseTimeMs)', 'avgLatency')
.addSelect('COUNT(CASE WHEN usage.statusCode >= 200 AND usage.statusCode < 300 THEN 1 END)', 'successCount')
.where('usage.tenantId = :tenantId', { tenantId })
.andWhere('usage.createdAt >= CURRENT_DATE')
.getRawOne();
const totalToday = parseInt(stats.totalToday || '0', 10);
const avgLatency = Math.round(parseFloat(stats.avgLatency || '0'));
const successRate = totalToday > 0
? Math.round((parseInt(stats.successCount || '0', 10) / totalToday) * 100)
: 100;
return {
monthlyUsage,
totalToday,
avgLatency,
successRate,
lastUpdated: new Date()
};
}
private getMonthlyKey(tenantId: string): string {
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
return `usage:${tenantId}:${yearMonth}`;
}
}
File diff suppressed because one or more lines are too long
+647
View File
@@ -0,0 +1,647 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jordan Map Platform | Developer Dashboard</title>
<!-- Tailwind CSS Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- MapLibre GL JS -->
<script src="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.css" rel="stylesheet" />
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Firebase SDK (v10.12.0) -->
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.0/firebase-auth-compat.js"></script>
<script src="js/firebase-config.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
950: '#0a0a0b',
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Cairo:wght@400;600;700;900&display=swap');
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #0a0a0b;
color: #f8fafc;
}
/* RTL Specifics */
.rtl-mode #main-content {
text-align: right;
}
.rtl-mode .nav-link {
flex-direction: row-reverse;
}
.glass {
background: rgba(15, 23, 42, 0.6);
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;
}
.btn {
@apply flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold transition-all duration-300 active:scale-95 text-sm;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-500 shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-slate-900 text-slate-400 hover:text-white border border-slate-800 hover:border-slate-700;
}
/* Nav logic */
.page-section {
display: none;
}
.page-section.active {
display: block;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Sidebar active state */
.nav-link.active {
@apply bg-blue-600/10 text-blue-400 border-r-2 border-blue-600;
}
</style>
</head>
<body class="overflow-hidden h-screen flex bg-[#0a0a0b]">
<!-- Login Section -->
<section id="login-section" class="fixed inset-0 z-[200] flex items-center justify-center bg-gradient-to-br from-slate-950 via-[#0a0a0b] to-blue-950/20 overflow-hidden">
<div class="absolute inset-0 opacity-20 pointer-events-none">
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-600 rounded-full blur-[128px] animate-pulse"></div>
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-cyan-600 rounded-full blur-[128px] animate-pulse delay-700"></div>
</div>
<div class="glass max-w-md w-full p-10 rounded-[2.5rem] relative z-10 text-center border-white/10 shadow-2xl">
<div class="w-20 h-20 rounded-2xl bg-gradient-to-tr from-blue-600 to-cyan-400 flex items-center justify-center text-white shadow-2xl shadow-blue-500/30 mx-auto mb-8 rotate-3">
<i data-lucide="layers" class="w-10 h-10"></i>
</div>
<h1 class="text-4xl font-black tracking-tight mb-3 text-gradient">Intaleq Maps</h1>
<p class="text-slate-400 font-medium mb-10 leading-relaxed">The premium developer platform for mapping services in Jordan & Syria.</p>
<button onclick="auth.signInWithGoogle()" class="w-full btn bg-white text-slate-950 hover:bg-slate-100 py-4 text-base font-black shadow-xl flex items-center justify-center gap-3 active:scale-[0.98] transition-all rounded-2xl">
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
<p class="mt-8 text-[11px] text-slate-500 font-bold uppercase tracking-widest">Enterprise Ready · Secure Access</p>
</div>
</section>
<!-- Sidebar -->
<aside id="main-sidebar" class="hidden w-64 border-r border-slate-800 bg-slate-950/50 backdrop-blur-xl flex flex-col z-50">
<div class="p-8">
<div class="flex items-center gap-3 group cursor-pointer">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-blue-600 to-cyan-400 flex items-center justify-center text-white shadow-lg shadow-blue-500/20 group-hover:rotate-12 transition-transform">
<i data-lucide="layers" class="w-6 h-6"></i>
</div>
<div>
<h1 class="font-black text-lg tracking-tight leading-none">Intaleq</h1>
<span class="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-500">Maps SaaS</span>
</div>
</div>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4">
<a href="#home" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-home">
<i data-lucide="layout-dashboard" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-dashboard">Dashboard</span>
</a>
<a href="#playground" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-playground">
<i data-lucide="terminal" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-playground">Playground</span>
</a>
<a href="#analytics" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-analytics">
<i data-lucide="bar-chart-3" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-analytics">Analytics</span>
</a>
<a href="#billing" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-billing">
<i data-lucide="credit-card" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-billing">Billing</span>
</a>
<a href="#refinement" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group hidden" id="nav-refinement">
<i data-lucide="map-pin" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-audit">Place Audit</span>
</a>
<a href="#docs" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-docs">
<i data-lucide="book-open" class="w-5 h-5 group-hover:text-white transition-colors"></i>
<span data-i18n="side-docs">Documentation</span>
</a>
</nav>
<div class="p-6">
<div class="glass p-5 rounded-2xl border border-blue-500/10 bg-gradient-to-br from-blue-600/5 to-transparent">
<p class="text-[10px] font-black uppercase tracking-wider text-blue-400 mb-2">Beta Access</p>
<p class="text-xs text-slate-400 leading-relaxed font-medium mb-4">You're currently on the free sandbox tier.</p>
<a href="#billing" class="w-full btn btn-primary !py-2 !text-xs text-center flex items-center justify-center" data-i18n="side-upgrade">Upgrade Plan</a>
</div>
</div>
</aside>
<!-- Main Content -->
<div id="main-content" class="hidden flex-1 flex flex-col min-w-0 bg-gradient-to-br from-slate-950 via-[#0f1115] to-slate-950 overflow-y-auto">
<!-- Header -->
<header class="h-20 border-b border-white/[0.03] flex items-center justify-between px-8 sticky top-0 bg-slate-950/50 backdrop-blur-md z-40">
<div class="flex items-center gap-4">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span class="text-xs font-black uppercase tracking-widest text-slate-500" data-i18n="system-status">System Operational</span>
</div>
<div class="flex items-center gap-6">
<!-- Language Toggle -->
<button onclick="i18n.toggle()" class="px-3 py-1.5 rounded-lg border border-white/10 hover:bg-white/5 transition-all text-xs font-bold text-slate-400 flex items-center gap-2" data-i18n="lang-toggle">
العربية
</button>
<div class="flex flex-col items-end">
<p class="text-sm font-bold" id="tenant-name">Loading...</p>
<p class="text-[10px] text-slate-500 font-medium" id="tenant-email">developer@intaleq.com</p>
</div>
<div class="w-10 h-10 rounded-full bg-slate-900 border border-slate-800 flex items-center justify-center text-blue-400">
<i data-lucide="user" class="w-5 h-5"></i>
</div>
</div>
</header>
<main class="p-12 max-w-7xl mx-auto w-full">
<!-- Home Section -->
<section id="home" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2" id="welcome-msg">Welcome back...</h2>
<p class="text-slate-400">Everything you need to build with premium Jordan Map Platform API</p>
</div>
<!-- KPI Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-12">
<div class="glass p-6 rounded-2xl group border-[#1e293b]">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-blue-400">
<i data-lucide="zap" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500">+12.5%</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1" data-i18n="kpi-total-req">Total Requests</p>
<div class="text-2xl font-bold tracking-tight" id="stat-total-req">...</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-emerald-400">
<i data-lucide="shield-check" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500" id="stat-success-rate">99.9%</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1" data-i18n="kpi-success-rate">Success Rate</p>
<div class="text-2xl font-bold tracking-tight" id="stat-success-percent">...</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-cyan-400">
<i data-lucide="activity" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500">Live</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1" data-i18n="kpi-active-keys">Active Keys</p>
<div class="text-2xl font-bold tracking-tight" id="active-keys-count">...</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-violet-400">
<i data-lucide="trending-up" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500" id="stat-latency-val">-ms</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1" data-i18n="kpi-latency">Avg Latency</p>
<div class="text-2xl font-bold tracking-tight" id="stat-avg-latency">...</div>
</div>
<!-- Usage Battery Card -->
<div class="glass p-6 rounded-2xl group bg-gradient-to-br from-blue-600/5 to-transparent border-blue-500/10">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-blue-500">
<i data-lucide="battery-charging" class="w-5 h-5"></i>
</div>
<span class="text-[10px] font-black uppercase text-blue-500 tracking-widest" id="usage-percentage-label">...</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-3" data-i18n="quota-card-title">Monthly Quota</p>
<div class="w-full h-2 bg-slate-900 rounded-full overflow-hidden mb-2">
<div id="usage-progress-bar" class="h-full bg-blue-500 transition-all duration-1000" style="width: 0%"></div>
</div>
<p class="text-[10px] text-slate-500 font-bold" id="usage-limit-text">... requests left</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<!-- Traffic Mockup -->
<div class="lg:col-span-2 glass rounded-2xl p-6 relative overflow-hidden">
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-lg font-bold">Request Traffic</h3>
<p class="text-sm text-slate-500">Live traffic across all API endpoints</p>
</div>
<a href="#analytics" class="text-xs text-blue-500 hover:text-blue-400 font-bold flex items-center gap-1 transition-colors">
Full Analytics <i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
</a>
</div>
<div class="h-64 flex items-end gap-1.5 px-2 relative" id="traffic-bars">
<!-- Premium Animated Bars via JS -->
</div>
</div>
<!-- Quick Actions -->
<div class="glass rounded-2xl p-6 bg-gradient-to-br from-blue-600/10 to-transparent border-blue-500/10">
<h3 class="text-lg font-bold mb-2">Quick Start</h3>
<p class="text-sm text-slate-500 mb-6 font-medium">Get started with our lightweight SDK in seconds.</p>
<div class="space-y-4">
<div class="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs">
<p class="text-slate-500 mb-2"># Install with npm</p>
<p class="text-blue-400">npm <span class="text-slate-200">install @intaleq/maps-gl</span></p>
</div>
<button class="w-full btn btn-secondary text-sm group" onclick="window.open('/api/docs', '_blank')">
<i data-lucide="terminal" class="w-4 h-4 text-blue-400 group-hover:scale-110 transition-transform"></i>
View API Reference
</button>
<a href="#playground" class="w-full btn btn-secondary text-sm group">
<i data-lucide="globe" class="w-4 h-4 text-cyan-400 group-hover:scale-110 transition-transform"></i>
Try Maps Playground
</a>
</div>
</div>
</div>
<!-- API Keys Table -->
<div class="glass rounded-2xl overflow-hidden mb-12">
<div class="p-6 border-b border-slate-800 flex items-center justify-between bg-white/[0.02]">
<div>
<h3 class="text-lg font-bold">Your API Keys</h3>
<p class="text-sm text-slate-500">Manage keys for your applications</p>
</div>
<button class="btn btn-primary" onclick="app.toggleModal('create-key-modal', true)" id="create-key-btn">
<i data-lucide="plus" class="w-4 h-4"></i>
Create New Key
</button>
</div>
<div class="overflow-x-auto min-h-[200px]">
<table class="w-full text-left">
<thead>
<tr class="text-xs uppercase tracking-widest text-slate-500 bg-slate-900/40">
<th class="px-6 py-4 font-black">Name</th>
<th class="px-6 py-4 font-black">API Key</th>
<th class="px-6 py-4 font-black">Status</th>
<th class="px-6 py-4 font-black">Restrictions</th>
<th class="px-6 py-4 font-black text-right">Actions</th>
</tr>
</thead>
<tbody id="keys-table-body" class="divide-y divide-slate-800/50">
<!-- Injected by JS -->
<tr>
<td colspan="5" class="py-20 text-center text-slate-500">
<div class="flex flex-col items-center gap-4">
<i data-lucide="loader-2" class="w-8 h-8 animate-spin text-blue-500"></i>
<p>Fetching your secure keys...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Playground Section -->
<section id="playground" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2">Maps Playground</h2>
<p class="text-slate-400">Test your API keys and visualize vector tiles in real-time</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Controls -->
<div class="lg:col-span-1 space-y-6">
<div class="glass p-6 rounded-2xl">
<h4 class="text-[10px] uppercase font-black tracking-widest text-slate-500 mb-4">Configuration</h4>
<div class="space-y-4">
<div>
<label class="text-xs text-slate-400 mb-2 block">Active API Key</label>
<select id="pg-key-select" class="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"></select>
</div>
<div>
<label class="text-xs text-slate-400 mb-2 block">Map Style</label>
<div class="flex gap-2">
<button onclick="playground.setStyle('obsidian')" id="style-obsidian" class="flex-1 py-3 text-xs font-bold rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/20">Obsidian</button>
<button onclick="playground.setStyle('light')" id="style-light" class="flex-1 py-3 text-xs font-bold rounded-xl bg-slate-900 text-slate-500">Light</button>
</div>
</div>
</div>
</div>
</div>
<!-- Map Container -->
<div class="lg:col-span-3 glass rounded-3xl overflow-hidden relative" style="min-height: 600px;">
<div id="map" class="absolute inset-0 w-full h-full bg-slate-900"></div>
<!-- Search Overlay -->
<div class="absolute top-6 left-6 w-full max-w-sm">
<div class="relative">
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 w-4 h-4"></i>
<input type="text" id="pg-search" placeholder="Search Amman, Jordan..."
class="w-full bg-slate-950/80 backdrop-blur-md border border-slate-800 rounded-2xl px-12 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 shadow-2xl">
</div>
</div>
</div>
</section>
<!-- Analytics Section -->
<section id="analytics" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2">Analytics</h2>
<p class="text-slate-400">Deep insights into your API performance</p>
</div>
<!-- Simple Chart Mockups -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
<div class="glass p-8 rounded-3xl">
<h3 class="text-xl font-bold mb-8">Request Volume (Overall)</h3>
<div class="h-80 w-full flex items-end justify-between gap-4 px-4" id="v-chart">
<!-- Injected by analytics.js -->
</div>
</div>
<div class="glass p-8 rounded-3xl">
<h3 class="text-xl font-bold mb-8">Success Ratio</h3>
<div class="flex items-center justify-center h-80">
<div class="relative w-48 h-48 rounded-full border-[12px] border-slate-900 flex items-center justify-center">
<svg class="absolute inset-0 w-full h-full -rotate-90">
<circle cx="96" cy="96" r="88" fill="transparent" stroke="currentColor" stroke-width="12" class="text-blue-600" id="success-circle" stroke-dasharray="552.9" stroke-dashoffset="0" />
</svg>
<span class="text-3xl font-black" id="success-percent-display">100%</span>
</div>
</div>
</div>
</div>
<div class="glass rounded-3xl overflow-hidden">
<div class="p-8 border-b border-white/5">
<h3 class="text-xl font-bold">Latency Breakdown</h3>
</div>
<div class="p-8 h-80 flex items-end justify-around gap-2" id="l-chart"></div>
</div>
</section>
<!-- Map Refinement Section -->
<section id="refinement" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2">Place Audit</h2>
<p class="text-slate-400">Review and approve user-suggested map locations</p>
</div>
<div class="glass rounded-3xl overflow-hidden">
<div class="overflow-x-auto min-h-[400px]">
<table class="w-full text-left">
<thead>
<tr class="text-xs uppercase tracking-widest text-slate-500 bg-slate-900/40">
<th class="px-8 py-5 font-black">Location Name</th>
<th class="px-8 py-5 font-black">Category</th>
<th class="px-8 py-5 font-black">Coordinates</th>
<th class="px-8 py-5 font-black">Submitter</th>
<th class="px-8 py-5 font-black text-right">Actions</th>
</tr>
</thead>
<tbody id="refinement-table-body" class="divide-y divide-slate-800/50">
<tr>
<td colspan="5" class="py-20 text-center text-slate-500">
<div class="flex flex-col items-center gap-4">
<i data-lucide="map-pin" class="w-8 h-8 opacity-20"></i>
<p>No pending location suggestions.</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Billing Section -->
<section id="billing" class="page-section p-10">
<div class="mb-12">
<h2 class="text-3xl font-black mb-2 text-gradient">Subscription & Billing</h2>
<p class="text-slate-400 font-medium">Manage your plan, payment methods, and invoice history.</p>
</div>
<!-- Current Plan Summary -->
<div class="glass p-8 rounded-[2rem] border-white/5 mb-12 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400">
<i data-lucide="crown" class="w-8 h-8"></i>
</div>
<div>
<h3 class="text-xl font-bold mb-1">Current Plan: <span class="current-plan-name text-blue-400">FREE</span></h3>
<p class="text-slate-500 text-sm font-medium">Your monthly usage resets on the 1st of each month.</p>
</div>
</div>
<div class="flex gap-3">
<div class="px-4 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs font-bold text-slate-400 flex items-center gap-2">
<i data-lucide="shield-check" class="w-4 h-4 text-emerald-400"></i> Active
</div>
</div>
</div>
<!-- Pricing Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
<!-- Free -->
<div id="plan-card-free" class="glass p-8 rounded-[2.5rem] border-white/5 flex flex-col hover:border-white/10 transition-all">
<div class="mb-8">
<span class="text-xs font-black tracking-widest text-slate-500 uppercase">Starter</span>
<h4 class="text-4xl font-black mt-2">$0 <span class="text-sm font-medium text-slate-500">/mo</span></h4>
</div>
<ul class="space-y-4 mb-10 flex-1">
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> 8,000 requests /mo
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> Standard Map Tiles
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> Basic Geocoding
</li>
</ul>
<button class="plan-btn w-full btn bg-slate-800 text-white py-4 rounded-2xl font-black text-sm active:scale-95 transition-all">Current Plan</button>
</div>
<!-- Pro -->
<div id="plan-card-pro" class="glass p-8 rounded-[2.5rem] border-blue-500/20 bg-blue-500/[0.02] flex flex-col relative overflow-hidden group">
<div class="absolute top-4 right-4 bg-blue-500 text-white text-[10px] font-black px-3 py-1 rounded-full uppercase tracking-widest shadow-lg shadow-blue-500/20">Popular</div>
<div class="mb-8">
<span class="text-xs font-black tracking-widest text-blue-400 uppercase">Professional</span>
<h4 class="text-4xl font-black mt-2 text-gradient">$40 <span class="text-sm font-medium text-slate-500">/mo</span></h4>
</div>
<ul class="space-y-4 mb-10 flex-1">
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> 50,000 requests /mo
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> 3D Building Extrusion
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> Advanced Routing API
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-blue-400"></i> Priority Support
</li>
</ul>
<div class="flex gap-2">
<button onclick="billing.startCheckout('PRO', 'PAYMOB')" class="plan-btn flex-1 btn bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-2xl font-black text-sm shadow-xl shadow-blue-500/20 active:scale-95 transition-all">Pay with Card</button>
<button onclick="billing.startCheckout('PRO', 'BINANCE')" class="p-4 rounded-2xl bg-white/5 border border-white/5 hover:bg-white/10 transition-all text-yellow-500" title="Pay with Crypto">
<i data-lucide="bitcoin" class="w-5 h-5"></i>
</button>
</div>
</div>
<!-- Enterprise -->
<div id="plan-card-enterprise" class="glass p-8 rounded-[2.5rem] border-white/5 flex flex-col hover:border-white/10 transition-all">
<div class="mb-8">
<span class="text-xs font-black tracking-widest text-violet-400 uppercase">Enterprise</span>
<h4 class="text-4xl font-black mt-2">Custom</h4>
</div>
<ul class="space-y-4 mb-10 flex-1">
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-violet-400"></i> Unlimited Requests
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-violet-400"></i> Custom Data Layers
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-violet-400"></i> SLA Guarantees
</li>
<li class="flex items-center gap-3 text-sm text-slate-300 font-medium">
<i data-lucide="check" class="w-4 h-4 text-violet-400"></i> Dedicated Architect
</li>
</ul>
<button class="plan-btn w-full btn border border-violet-500/30 text-violet-300 hover:bg-violet-500/10 py-4 rounded-2xl font-black text-sm active:scale-95 transition-all">Contact Sales</button>
</div>
</div>
<!-- Invoice History -->
<div class="glass rounded-[2rem] border-white/5 overflow-hidden">
<div class="p-8 border-b border-white/5 flex items-center justify-between">
<h3 class="text-lg font-bold">Transaction History</h3>
<i data-lucide="receipt" class="w-5 h-5 text-slate-500"></i>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="bg-white/[0.01]">
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-slate-500">Date</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-slate-500">Amount</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-slate-500">Provider</th>
<th class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-slate-500">Status</th>
</tr>
</thead>
<tbody id="invoice-table-body">
<!-- Loaded via JS -->
</tbody>
</table>
</div>
</div>
<!-- Documentation Section -->
<section id="docs" class="page-section h-full">
<div class="flex h-full gap-8">
<!-- Docs Nav -->
<div class="w-64 flex flex-col gap-2 shrink-0">
<div class="mb-4">
<h2 class="text-xl font-black text-gradient">Guides</h2>
</div>
<a href="javascript:void(0)" data-section="getting-started" class="docs-nav-link active bg-blue-500/10 text-blue-400 p-4 rounded-2xl text-sm font-bold flex items-center gap-3 transition-all hover:bg-blue-500/5">
<i data-lucide="rocket" class="w-4 h-4"></i> Getting Started
</a>
<a href="javascript:void(0)" data-section="tiles-api" class="docs-nav-link p-4 rounded-2xl text-sm font-bold text-slate-500 flex items-center gap-3 transition-all hover:bg-white/5 hover:text-white">
<i data-lucide="map" class="w-4 h-4"></i> Map Tiles API
</a>
<a href="javascript:void(0)" data-section="geocoding-api" class="docs-nav-link p-4 rounded-2xl text-sm font-bold text-slate-500 flex items-center gap-3 transition-all hover:bg-white/5 hover:text-white">
<i data-lucide="search" class="w-4 h-4"></i> Geocoding API
</a>
<a href="javascript:void(0)" data-section="routing-api" class="docs-nav-link p-4 rounded-2xl text-sm font-bold text-slate-500 flex items-center gap-3 transition-all hover:bg-white/5 hover:text-white">
<i data-lucide="navigation" class="w-4 h-4"></i> Routing API
</a>
</div>
<!-- Docs Content Area -->
<div class="flex-1 glass rounded-[2.5rem] border-white/5 p-10 overflow-y-auto" id="docs-content">
<!-- Loaded via docs.js -->
</div>
</div>
</section>
</main>
</div>
<!-- Modals -->
<div id="create-key-modal" class="fixed inset-0 z-[100] hidden">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" onclick="app.toggleModal('create-key-modal', false)"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="glass w-full max-w-md p-8 rounded-3xl animate-in fade-in zoom-in duration-300">
<h2 class="text-2xl font-bold mb-2">Create API Key</h2>
<p class="text-sm text-slate-500 mb-8">Set up a new access point for your application.</p>
<form id="create-key-form" class="space-y-6">
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-500 mb-2">Key Name</label>
<input type="text" id="new-key-name" placeholder="e.g. Production Web App" required
class="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">
</div>
<div class="flex items-center gap-3 pt-4 border-t border-slate-800">
<button type="button" onclick="app.toggleModal('create-key-modal', false)" class="flex-1 btn btn-secondary">
Cancel
</button>
<button type="submit" id="btn-submit-key" class="flex-1 btn btn-primary">
Create Key
</button>
</div>
</form>
</div>
</div>
</div>
<!-- core script -->
<script src="js/i18n.js"></script>
<script src="js/auth.js"></script>
<script src="js/app.js"></script>
<script src="js/playground.js"></script>
<script src="js/analytics.js"></script>
<script src="js/billing.js"></script>
<script src="js/docs.js"></script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+211 -320
View File
@@ -3,372 +3,263 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jordan Map Platform | Developer Dashboard</title>
<!-- Tailwind CSS Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- MapLibre GL JS -->
<script src="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.css" rel="stylesheet" />
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
950: '#0a0a0b',
}
}
}
}
}
</script>
<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>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #0a0a0b;
color: #f8fafc;
/* 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;
}
.glass {
background: rgba(15, 23, 42, 0.6);
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;
}
.btn {
@apply flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold transition-all duration-300 active:scale-95 text-sm;
}
.btn-primary {
@apply bg-blue-600 text-white hover:bg-blue-500 shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-slate-900 text-slate-400 hover:text-white border border-slate-800 hover:border-slate-700;
}
/* Nav logic */
.page-section {
display: none;
}
.page-section.active {
display: block;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Sidebar active state */
.nav-link.active {
@apply bg-blue-600/10 text-blue-400 border-r-2 border-blue-600;
}
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-hidden h-screen flex">
<body class="overflow-x-hidden selection:bg-blue-500/30">
<!-- Sidebar -->
<aside class="w-64 border-r border-slate-800 bg-slate-950/50 backdrop-blur-xl flex flex-col z-50">
<div class="p-8">
<div class="flex items-center gap-3 group cursor-pointer">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-blue-600 to-cyan-400 flex items-center justify-center text-white shadow-lg shadow-blue-500/20 group-hover:rotate-12 transition-transform">
<i data-lucide="layers" class="w-6 h-6"></i>
<!-- 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>
<div>
<h1 class="font-black text-lg tracking-tight leading-none">Intaleq</h1>
<span class="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-500">Maps SaaS</span>
<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 class="flex-1 px-4 space-y-2 mt-4">
<a href="#home" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-home">
<i data-lucide="layout-dashboard" class="w-5 h-5 group-hover:text-white transition-colors"></i>
Dashboard
</a>
<a href="#playground" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-playground">
<i data-lucide="terminal" class="w-5 h-5 group-hover:text-white transition-colors"></i>
Playground
</a>
<a href="#analytics" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-analytics">
<i data-lucide="bar-chart-3" class="w-5 h-5 group-hover:text-white transition-colors"></i>
Analytics
</a>
<a href="#billing" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-billing">
<i data-lucide="credit-card" class="w-5 h-5 group-hover:text-white transition-colors"></i>
Billing
</a>
<a href="#docs" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-docs">
<i data-lucide="book-open" class="w-5 h-5 group-hover:text-white transition-colors"></i>
Documentation
</a>
</nav>
<div class="p-6">
<div class="glass p-5 rounded-2xl border border-blue-500/10 bg-gradient-to-br from-blue-600/5 to-transparent">
<p class="text-[10px] font-black uppercase tracking-wider text-blue-400 mb-2">Beta Access</p>
<p class="text-xs text-slate-400 leading-relaxed font-medium mb-4">You're currently on the free sandbox tier.</p>
<button class="w-full btn btn-primary !py-2 !text-xs">Upgrade Plan</button>
<!-- 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>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0 bg-gradient-to-br from-slate-950 via-[#0f1115] to-slate-950 overflow-y-auto">
<!-- Header -->
<header class="h-20 border-b border-white/[0.03] flex items-center justify-between px-8 sticky top-0 bg-slate-950/50 backdrop-blur-md z-40">
<div class="flex items-center gap-4">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span class="text-xs font-black uppercase tracking-widest text-slate-500">System Operational</span>
</div>
<div class="flex items-center gap-6">
<div class="flex flex-col items-end">
<p class="text-sm font-bold" id="tenant-name">Loading...</p>
<p class="text-[10px] text-slate-500 font-medium" id="tenant-email">developer@intaleq.com</p>
</div>
<div class="w-10 h-10 rounded-full bg-slate-900 border border-slate-800 flex items-center justify-center text-blue-400">
<i data-lucide="user" class="w-5 h-5"></i>
</div>
</div>
</header>
<main class="p-12 max-w-7xl mx-auto w-full">
<!-- Home Section -->
<section id="home" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2" id="welcome-msg">Welcome back...</h2>
<p class="text-slate-400">Everything you need to build with premium Jordan Map Platform API</p>
</div>
<!-- KPI Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
<div class="glass p-6 rounded-2xl group border-[#1e293b]">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-blue-400">
<i data-lucide="zap" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500">+12.5%</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1">Total Requests</p>
<div class="text-2xl font-bold tracking-tight">42,891</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-emerald-400">
<i data-lucide="shield-check" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500">+0.01%</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1">Success Rate</p>
<div class="text-2xl font-bold tracking-tight">99.98%</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-cyan-400">
<i data-lucide="activity" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500">Stable</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1">Active Keys</p>
<div class="text-2xl font-bold tracking-tight" id="active-keys-count">...</div>
</div>
<div class="glass p-6 rounded-2xl group">
<div class="flex items-center justify-between mb-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-violet-400">
<i data-lucide="trending-up" class="w-5 h-5"></i>
</div>
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500">-12ms</span>
</div>
<p class="text-sm text-slate-500 font-medium mb-1">Map Load Speed</p>
<div class="text-2xl font-bold tracking-tight">342ms</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<!-- Traffic Mockup -->
<div class="lg:col-span-2 glass rounded-2xl p-6 relative overflow-hidden">
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-lg font-bold">Request Traffic</h3>
<p class="text-sm text-slate-500">Live traffic across all API endpoints</p>
</div>
<a href="#analytics" class="text-xs text-blue-500 hover:text-blue-400 font-bold flex items-center gap-1 transition-colors">
Full Analytics <i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
<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>
</div>
<div class="h-64 flex items-end gap-2 px-2 relative" id="traffic-bars">
<!-- Bars injected by JS -->
</div>
</div>
<!-- Quick Actions -->
<div class="glass rounded-2xl p-6 bg-gradient-to-br from-blue-600/10 to-transparent border-blue-500/10">
<h3 class="text-lg font-bold mb-2">Quick Start</h3>
<p class="text-sm text-slate-500 mb-6 font-medium">Get started with our lightweight SDK in seconds.</p>
<div class="space-y-4">
<div class="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs">
<p class="text-slate-500 mb-2"># Install with npm</p>
<p class="text-blue-400">npm <span class="text-slate-200">install @intaleq/maps-gl</span></p>
</div>
<button class="w-full btn btn-secondary text-sm group" onclick="window.open('/api/docs', '_blank')">
<i data-lucide="terminal" class="w-4 h-4 text-blue-400 group-hover:scale-110 transition-transform"></i>
View API Reference
</button>
<a href="#playground" class="w-full btn btn-secondary text-sm group">
<i data-lucide="globe" class="w-4 h-4 text-cyan-400 group-hover:scale-110 transition-transform"></i>
Try Maps Playground
<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>
<!-- API Keys Table -->
<div class="glass rounded-2xl overflow-hidden mb-12">
<div class="p-6 border-b border-slate-800 flex items-center justify-between bg-white/[0.02]">
<div>
<h3 class="text-lg font-bold">Your API Keys</h3>
<p class="text-sm text-slate-500">Manage keys for your applications</p>
</div>
<button class="btn btn-primary" onclick="app.toggleModal('create-key-modal', true)" id="create-key-btn">
<i data-lucide="plus" class="w-4 h-4"></i>
Create New Key
</button>
</div>
<div class="overflow-x-auto min-h-[200px]">
<div class="glass rounded-[2.5rem] overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="text-xs uppercase tracking-widest text-slate-500 bg-slate-900/40">
<th class="px-6 py-4 font-black">Name</th>
<th class="px-6 py-4 font-black">API Key</th>
<th class="px-6 py-4 font-black">Status</th>
<th class="px-6 py-4 font-black">Restrictions</th>
<th class="px-6 py-4 font-black text-right">Actions</th>
<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 id="keys-table-body" class="divide-y divide-slate-800/50">
<!-- Injected by JS -->
<tbody class="divide-y divide-white/5">
<tr>
<td colspan="5" class="py-20 text-center text-slate-500">
<div class="flex flex-col items-center gap-4">
<i data-lucide="loader-2" class="w-8 h-8 animate-spin text-blue-500"></i>
<p>Fetching your secure keys...</p>
</div>
</td>
<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>
<!-- Playground Section -->
<section id="playground" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2">Maps Playground</h2>
<p class="text-slate-400">Test your API keys and visualize vector tiles in real-time</p>
<!-- 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>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Sidebar Controls -->
<div class="lg:col-span-1 space-y-6">
<div class="glass p-6 rounded-2xl">
<h4 class="text-[10px] uppercase font-black tracking-widest text-slate-500 mb-4">Configuration</h4>
<div class="space-y-4">
<div>
<label class="text-xs text-slate-400 mb-2 block">Active API Key</label>
<select id="pg-key-select" class="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"></select>
<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>
<label class="text-xs text-slate-400 mb-2 block">Map Style</label>
<div class="flex gap-2">
<button onclick="playground.setStyle('obsidian')" id="style-obsidian" class="flex-1 py-3 text-xs font-bold rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/20">Obsidian</button>
<button onclick="playground.setStyle('light')" id="style-light" class="flex-1 py-3 text-xs font-bold rounded-xl bg-slate-900 text-slate-500">Light</button>
<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>
</div>
</div>
<!-- Map Container -->
<div class="lg:col-span-3 glass rounded-3xl overflow-hidden relative" style="height: 600px;">
<div id="map" class="absolute inset-0"></div>
<!-- Search Overlay -->
<div class="absolute top-6 left-6 w-full max-w-sm">
<div class="relative">
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 w-4 h-4"></i>
<input type="text" id="pg-search" placeholder="Search Amman, Jordan..."
class="w-full bg-slate-950/80 backdrop-blur-md border border-slate-800 rounded-2xl px-12 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 shadow-2xl">
</div>
</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>
<!-- Analytics Section -->
<section id="analytics" class="page-section">
<div class="mb-12">
<h2 class="text-4xl text-gradient mb-2">Analytics</h2>
<p class="text-slate-400">Deep insights into your API performance</p>
</div>
<!-- Simple Chart Mockups -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="glass p-8 rounded-3xl">
<h3 class="text-xl font-bold mb-8">Request Volume (Last 7 Days)</h3>
<div class="h-80 w-full flex items-end justify-between gap-4 px-4" id="v-chart"></div>
</div>
<div class="glass p-8 rounded-3xl">
<h3 class="text-xl font-bold mb-8">Service Latency (ms)</h3>
<div class="h-80 w-full flex items-end justify-between gap-4 px-4" id="l-chart"></div>
<!-- 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>
</main>
<!-- 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>
<!-- Modals -->
<div id="create-key-modal" class="fixed inset-0 z-[100] hidden">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" onclick="app.toggleModal('create-key-modal', false)"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="glass w-full max-w-md p-8 rounded-3xl animate-in fade-in zoom-in duration-300">
<h2 class="text-2xl font-bold mb-2">Create API Key</h2>
<p class="text-sm text-slate-500 mb-8">Set up a new access point for your application.</p>
<form id="create-key-form" class="space-y-6">
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-500 mb-2">Key Name</label>
<input type="text" id="new-key-name" placeholder="e.g. Production Web App" required
class="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">
</div>
<div class="flex gap-4 pt-4">
<button type="button" class="flex-1 btn btn-secondary" onclick="app.toggleModal('create-key-modal', false)">Cancel</button>
<button type="submit" class="flex-1 btn btn-primary" id="btn-submit-key">Create Key</button>
</div>
</form>
</div>
</div>
</div>
<script src="js/i18n.js"></script>
<script>
lucide.createIcons();
<!-- core script -->
<script src="js/app.js"></script>
<script src="js/playground.js"></script>
<script src="js/analytics.js"></script>
// 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>
</html>
+109 -21
View File
@@ -13,50 +13,138 @@ const analytics = {
{ name: 'Sun', requests: 3490, latency: 225 },
],
init: () => {
console.log('📈 Initializing Analytics...');
analytics.renderVolumeChart();
init: async () => {
console.log('📈 Initializing Live Analytics...');
const headers = auth.getAuthHeader();
// 1. Fetch Summary for Success Ratio
try {
const summaryRes = await fetch('/api/usage/summary', { headers });
if (summaryRes.ok) {
const summary = await summaryRes.json();
analytics.renderSuccessRatio(summary.successRate);
}
} catch (e) { console.error(e); }
// 2. Fetch History for Volume Chart
const history = await analytics.fetchHistory();
if (history && history.length > 0) {
analytics.renderVolumeChart(history);
} else {
analytics.renderPlaceholder();
}
analytics.renderLatencyChart();
},
renderVolumeChart: () => {
renderSuccessRatio: (percent) => {
const circle = document.getElementById('success-circle');
const display = document.getElementById('success-percent-display');
if (!circle || !display) return;
display.textContent = `${percent}%`;
// Circumference is 552.9 (2 * PI * 88)
const offset = 552.9 - (percent / 100) * 552.9;
circle.style.strokeDashoffset = offset;
circle.classList.toggle('text-emerald-500', percent >= 95);
circle.classList.toggle('text-blue-600', percent < 95);
},
fetchHistory: async () => {
try {
const headers = auth.getAuthHeader();
const res = await fetch('/api/usage/history?days=7', { headers });
if (res.ok) {
const raw = await res.json();
// Map raw database records to chart data
return raw.map(item => {
const date = new Date(item.date);
return {
name: date.toLocaleDateString('en-US', { weekday: 'short' }),
requests: parseInt(item.count, 10)
};
});
}
} catch (error) {
console.error('Failed to fetch analytics history', error);
}
return null;
},
renderVolumeChart: (data) => {
const container = document.getElementById('v-chart');
if (!container) return;
const max = Math.max(...analytics.mockData.map(d => d.requests));
const max = Math.max(...data.map(d => d.requests), 1);
container.innerHTML = analytics.mockData.map(d => `
<div class="flex-1 flex flex-col items-center gap-2 group">
<div class="w-full bg-blue-600/20 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
<div class="w-full bg-gradient-to-t from-blue-600 to-blue-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
style="height: ${(d.requests / max) * 100}%">
container.innerHTML = data.map((d, i) => `
<div class="flex-1 flex flex-col items-center gap-4 group h-full">
<div class="flex-1 w-full bg-slate-900/40 rounded-2xl relative overflow-hidden flex items-end p-1 border border-white/[0.03] backdrop-blur-sm">
<div class="w-full bg-gradient-to-t from-blue-600 via-blue-500 to-cyan-400 rounded-xl transition-all duration-1000 ease-out hover:brightness-125 shadow-[0_0_30px_rgba(37,99,235,0.2)]"
style="height: 0%; transition-delay: ${i * 50}ms">
<script>
setTimeout(() => {
document.querySelectorAll('.group h-full div[style*="height: 0%"]')[0].style.height = "${(d.requests / max) * 100}%";
}, 100);
</script>
</div>
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.requests}</span>
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0 z-10">
<span class="text-[10px] font-black bg-blue-600 text-white px-3 py-1.5 rounded-lg shadow-2xl border border-blue-400/30 mb-2">${d.requests.toLocaleString()}</span>
</div>
</div>
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">${d.name}</span>
</div>
`).join('');
// Trigger animations after render
setTimeout(() => {
container.querySelectorAll('.w-full.bg-gradient-to-t').forEach((el, index) => {
const height = el.parentElement.parentElement.dataset.height;
el.style.height = el.getAttribute('data-target-height');
});
}, 100);
},
renderPlaceholder: () => {
const container = document.getElementById('v-chart');
if (container) container.innerHTML = `
<div class="w-full h-full flex flex-col items-center justify-center text-slate-500 gap-4">
<div class="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center opacity-50">
<i data-lucide="bar-chart" class="w-8 h-8"></i>
</div>
<p class="text-xs italic font-bold tracking-widest uppercase opacity-40">No activity recorded for this period</p>
</div>`;
if (window.lucide) lucide.createIcons();
},
renderLatencyChart: () => {
const container = document.getElementById('l-chart');
if (!container) return;
const max = Math.max(...analytics.mockData.map(d => d.latency));
const mockLatency = [
{ name: 'Mon', latency: 240 },
{ name: 'Tue', latency: 198 },
{ name: 'Wed', latency: 310 },
{ name: 'Thu', latency: 208 },
{ name: 'Fri', latency: 250 },
{ name: 'Sat', latency: 210 },
{ name: 'Sun', latency: 225 },
];
container.innerHTML = analytics.mockData.map(d => `
<div class="flex-1 flex flex-col items-center gap-2 group">
<div class="w-full bg-violet-600/10 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
<div class="w-full bg-gradient-to-t from-violet-600 to-violet-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
const max = Math.max(...mockLatency.map(d => d.latency));
container.innerHTML = mockLatency.map((d, i) => `
<div class="flex-1 flex flex-col items-center gap-4 group h-full">
<div class="flex-1 w-full bg-slate-900/40 rounded-2xl relative overflow-hidden flex items-end p-1 border border-white/[0.03] backdrop-blur-sm">
<div class="w-full bg-gradient-to-t from-violet-600 via-violet-500 to-fuchsia-400 rounded-xl transition-all duration-1000 ease-out hover:brightness-125 shadow-[0_0_30px_rgba(139,92,246,0.2)]"
style="height: ${(d.latency / max) * 100}%">
</div>
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.latency}ms</span>
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0 z-10">
<span class="text-[10px] font-black bg-violet-600 text-white px-3 py-1.5 rounded-lg shadow-2xl border border-violet-400/30 mb-2">${d.latency}ms</span>
</div>
</div>
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">${d.name}</span>
</div>
`).join('');
}
+86 -22
View File
@@ -11,13 +11,18 @@ const app = {
},
init: async () => {
console.log('🚀 Dashboard Initializing...');
console.log('🚀 Dashboard Initializing Components...');
app.bindEvents();
app.handleRouting();
await app.fetchData();
lucide.createIcons();
},
onAuthenticated: async () => {
console.log('🔑 User Authenticated, Fetching Data...');
await app.fetchData();
app.updateStats();
},
bindEvents: () => {
window.addEventListener('hashchange', app.handleRouting);
@@ -50,25 +55,31 @@ const app = {
playground.init(app.state.keys);
} else if (hash === 'analytics') {
analytics.init();
} else if (hash === 'billing') {
billing.init();
} else if (hash === 'refinement') {
refinement.init();
} else if (hash === 'docs') {
docs.init();
}
},
fetchData: async () => {
const headers = auth.getAuthHeader();
try {
// Fetch Tenant
const tenantRes = await fetch('/api/auth/management/me');
const tenantRes = await fetch('/api/auth/management/me', { headers });
if (tenantRes.ok) {
app.state.tenant = await tenantRes.data || await tenantRes.json();
app.state.tenant = await tenantRes.json();
app.updateHeader();
}
// Fetch Keys
if (app.state.tenant && app.state.tenant.id) {
const keysRes = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`);
if (app.state.tenant) {
const keysRes = await fetch('/api/auth/management/keys', { headers });
if (keysRes.ok) {
app.state.keys = await keysRes.json();
app.renderKeysTable();
app.updateStats();
}
}
} catch (error) {
@@ -80,21 +91,73 @@ const app = {
if (!app.state.tenant) return;
document.getElementById('tenant-name').textContent = app.state.tenant.name;
document.getElementById('tenant-email').textContent = app.state.tenant.email;
document.getElementById('welcome-msg').textContent = `Welcome back, ${app.state.tenant.name}`;
document.getElementById('welcome-msg').textContent = `Welcome back, ${app.state.tenant.name.split(' ')[0]}`;
// Role-based UI visibility
if (app.state.tenant.role === 'ADMIN') {
const auditLink = document.getElementById('nav-refinement');
if (auditLink) auditLink.classList.remove('hidden');
}
// Update user avatar if photoUrl exists
if (app.state.tenant.photoUrl) {
const avatarContainer = document.querySelector('.w-10.h-10.rounded-full.bg-slate-900');
if (avatarContainer) {
avatarContainer.innerHTML = `<img src="${app.state.tenant.photoUrl}" class="w-full h-full rounded-full object-cover border border-blue-500/20" alt="Profile">`;
}
}
},
updateStats: () => {
updateStats: async () => {
try {
const headers = auth.getAuthHeader();
const res = await fetch('/api/usage/summary', { headers });
if (res.ok) {
const data = await res.json();
// Update KPI Cards
document.getElementById('active-keys-count').textContent = app.state.keys.length;
// Inject random bars for traffic
// Detailed Stats from getUsageSummary
if (document.getElementById('stat-total-req')) {
document.getElementById('stat-total-req').textContent = data.monthlyUsage.toLocaleString();
}
if (document.getElementById('stat-success-percent')) {
document.getElementById('stat-success-percent').textContent = `${data.successRate}%`;
document.getElementById('stat-success-rate').textContent = data.successRate >= 95 ? 'Excellent' : 'Stable';
}
if (document.getElementById('stat-avg-latency')) {
document.getElementById('stat-avg-latency').textContent = `${data.avgLatency}ms`;
document.getElementById('stat-latency-val').textContent = `-${Math.round(data.avgLatency * 0.1)}ms`;
}
// Update Progress bar & Quota card
const bar = document.getElementById('usage-progress-bar');
const label = document.getElementById('usage-percentage-label');
const limitText = document.getElementById('usage-limit-text');
if (bar) bar.style.width = `${data.percentage}%`;
if (label) label.textContent = `${data.percentage}% USED`;
if (limitText) {
const remaining = Math.max(0, data.limit - data.monthlyUsage);
limitText.textContent = `${remaining.toLocaleString()} requests left`;
}
}
} catch (error) {
console.error('Failed to update stats', error);
}
// Inject sample bars for visual flair if chart not ready
const container = document.getElementById('traffic-bars');
if (container) {
container.innerHTML = '';
const values = [40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90];
values.forEach(h => {
// High-density bars for a tech/premium feel
const values = [40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 50, 65, 80, 70, 30];
values.forEach((h, index) => {
const bar = document.createElement('div');
bar.className = 'flex-1 bg-gradient-to-t from-blue-600/20 to-blue-400/80 rounded-t-sm relative group hover:to-blue-300 transition-all';
bar.className = 'flex-1 bg-gradient-to-t from-blue-600/10 to-blue-400/60 rounded-t-[2px] relative group hover:to-blue-300 transition-all duration-500';
bar.style.height = `${h}%`;
bar.style.transitionDelay = `${index * 20}ms`;
bar.innerHTML = `<div class="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>`;
container.appendChild(bar);
});
@@ -112,12 +175,10 @@ const app = {
return;
}
// Disable create button if limit reached (per React logic)
if (app.state.keys.length >= 1) {
createBtn.disabled = true;
createBtn.title = "Limit of 1 API key per developer reached";
createBtn.innerHTML = '<i data-lucide="shield-alert" class="w-4 h-4"></i> Limit Reached';
}
// Enable create button (removed previous limit restrictiveness)
createBtn.disabled = false;
createBtn.title = "Create a new API key";
createBtn.innerHTML = '<i data-lucide="plus" class="w-4 h-4"></i> Create Key';
tbody.innerHTML = app.state.keys.map(key => {
const isVisible = app.state.showKeys[key.id];
@@ -195,9 +256,12 @@ const app = {
btn.disabled = true;
btn.textContent = 'Creating...';
const res = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`, {
const res = await fetch('/api/auth/management/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...auth.getAuthHeader()
},
body: JSON.stringify({ name, rateLimit: 100 })
});
@@ -217,5 +281,5 @@ const app = {
}
};
// Start app
// Start app components
document.addEventListener('DOMContentLoaded', app.init);
+80
View File
@@ -0,0 +1,80 @@
/**
* Firebase Authentication Logic for Intaleq Dashboard
*/
const auth = {
firebaseAuth: null,
currentUser: null,
idToken: null,
init: () => {
console.log('🔐 Initializing Auth Module...');
// 1. Initialize Firebase
firebase.initializeApp(firebaseConfig);
auth.firebaseAuth = firebase.auth();
// 2. Listen for Auth Changes
auth.firebaseAuth.onAuthStateChanged(async (user) => {
if (user) {
console.log('✅ User logged in:', user.email);
auth.currentUser = user;
auth.idToken = await user.getIdToken();
// Show Dashboard, Hide Login
auth.toggleUI(true);
// Initialize main app data
app.onAuthenticated();
} else {
console.log('❌ No active session.');
auth.currentUser = null;
auth.idToken = null;
// Show Login, Hide Dashboard
auth.toggleUI(false);
}
});
},
signInWithGoogle: async () => {
const provider = new firebase.auth.GoogleAuthProvider();
try {
await auth.firebaseAuth.signInWithPopup(provider);
} catch (error) {
console.error('Sign-in error:', error);
alert('Failed to sign in. Please try again.');
}
},
signOut: async () => {
try {
await auth.firebaseAuth.signOut();
} catch (error) {
console.error('Sign-out error:', error);
}
},
toggleUI: (isAuthenticated) => {
const loginSection = document.getElementById('login-section');
const mainSidebar = document.getElementById('main-sidebar');
const mainContent = document.getElementById('main-content');
if (isAuthenticated) {
if (loginSection) loginSection.classList.add('hidden');
if (mainSidebar) mainSidebar.classList.remove('hidden');
if (mainContent) mainContent.classList.remove('hidden');
} else {
if (loginSection) loginSection.classList.remove('hidden');
if (mainSidebar) mainSidebar.classList.add('hidden');
if (mainContent) mainContent.classList.add('hidden');
}
},
getAuthHeader: () => {
return auth.idToken ? { 'Authorization': `Bearer ${auth.idToken}` } : {};
}
};
// Start Auth on Load
document.addEventListener('DOMContentLoaded', auth.init);
+129
View File
@@ -0,0 +1,129 @@
/**
* Billing & Subscription Logic
*/
const billing = {
state: {
subscription: null,
invoices: []
},
init: async () => {
console.log('💳 Initializing Billing...');
await billing.fetchSubscription();
await billing.fetchInvoices();
billing.renderUI();
},
fetchSubscription: async () => {
try {
const headers = auth.getAuthHeader();
const res = await fetch('/api/billing/subscription', { headers });
if (res.ok) {
billing.state.subscription = await res.json();
}
} catch (error) {
console.error('Failed to fetch subscription', error);
}
},
fetchInvoices: async () => {
try {
const headers = auth.getAuthHeader();
const res = await fetch('/api/billing/invoices', { headers });
if (res.ok) {
billing.state.invoices = await res.json();
}
} catch (error) {
console.error('Failed to fetch invoices', error);
}
},
renderUI: () => {
const sub = billing.state.subscription;
if (!sub) return;
// 1. Update Current Plan Badges
document.querySelectorAll('.current-plan-name').forEach(el => el.textContent = sub.plan);
// 2. Render Plan Cards logic
const plans = ['FREE', 'PRO', 'ENTERPRISE'];
plans.forEach(p => {
const card = document.getElementById(`plan-card-${p.toLowerCase()}`);
if (card) {
const btn = card.querySelector('.plan-btn');
if (p === sub.plan) {
card.classList.add('border-blue-500/50');
if (btn) {
btn.textContent = 'Current Plan';
btn.disabled = true;
btn.classList.add('opacity-50');
}
} else {
card.classList.remove('border-blue-500/50');
if (btn) {
btn.textContent = p === 'ENTERPRISE' ? 'Contact Sales' : 'Upgrade Now';
btn.disabled = false;
btn.classList.remove('opacity-50');
}
}
}
});
// 3. Render Invoice History
const tbody = document.getElementById('invoice-table-body');
if (tbody) {
if (billing.state.invoices.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="py-10 text-center text-slate-500 text-xs">No transactions yet.</td></tr>`;
} else {
tbody.innerHTML = billing.state.invoices.map(inv => `
<tr class="border-b border-white/[0.02] hover:bg-white/[0.01]">
<td class="py-4 text-xs font-medium">${new Date(inv.createdAt).toLocaleDateString()}</td>
<td class="py-4 text-xs font-bold">$${inv.amount}</td>
<td class="py-4 text-xs">
<span class="px-2 py-0.5 rounded bg-slate-800 text-[10px] uppercase font-black">${inv.provider}</span>
</td>
<td class="py-4 text-xs">
<span class="status-badge ${inv.status.toLowerCase()}">${inv.status}</span>
</td>
</tr>
`).join('');
}
}
},
startCheckout: async (plan, provider) => {
try {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Processing...';
btn.disabled = true;
const headers = {
'Content-Type': 'application/json',
...auth.getAuthHeader()
};
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers,
body: JSON.stringify({ plan, provider })
});
if (res.ok) {
const data = await res.json();
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
} else {
alert('Checkout failed. Please try again.');
}
btn.textContent = originalText;
btn.disabled = false;
} catch (error) {
console.error('Checkout error', error);
alert('An error occurred during checkout.');
}
}
};
+146
View File
@@ -0,0 +1,146 @@
/**
* Documentation Engine for Intaleq Dashboard
*/
const docs = {
init: () => {
console.log('📚 Initializing Documentation...');
docs.bindEvents();
docs.renderSection('getting-started');
},
bindEvents: () => {
// Handle side-nav clicks
document.querySelectorAll('.docs-nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const section = e.currentTarget.getAttribute('data-section');
docs.renderSection(section);
// Active state
document.querySelectorAll('.docs-nav-link').forEach(l => l.classList.remove('active', 'bg-blue-500/10', 'text-blue-400'));
e.currentTarget.classList.add('active', 'bg-blue-500/10', 'text-blue-400');
});
});
},
renderSection: (id) => {
const container = document.getElementById('docs-content');
if (!container) return;
// Content repository
const content = {
'getting-started': `
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div>
<h3 class="text-3xl font-black mb-4">Getting Started</h3>
<p class="text-slate-400 leading-relaxed">Welcome to the Intaleq Map Platform. Our APIs allow you to integrate high-quality vector maps, geocoding, and routing into your web and mobile applications with ease.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="glass p-6 rounded-2xl border-white/5">
<h4 class="font-bold mb-2 flex items-center gap-2">
<i data-lucide="key" class="w-4 h-4 text-blue-400"></i>
1. Get an API Key
</h4>
<p class="text-xs text-slate-500">Go to the Credentials page and create your first API key.</p>
</div>
<div class="glass p-6 rounded-2xl border-white/5">
<h4 class="font-bold mb-2 flex items-center gap-2">
<i data-lucide="code" class="w-4 h-4 text-emerald-400"></i>
2. Install SDK
</h4>
<p class="text-xs text-slate-500">Use our MapLibre wrappers for JavaScript or Flutter.</p>
</div>
</div>
<div class="space-y-4">
<h4 class="text-lg font-bold">Base URL</h4>
<div class="bg-slate-900 rounded-xl p-4 font-mono text-sm border border-slate-800 text-blue-400">
https://map-dashbord.intaleqapp.com/api
</div>
</div>
</div>
`,
'tiles-api': `
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div>
<h3 class="text-3xl font-black mb-2">Vector Tiles API</h3>
<p class="text-slate-400">Render high-performance vector maps from our global database.</p>
</div>
<div class="space-y-6">
<div class="endpoint-card glass rounded-2xl border-white/5 overflow-hidden">
<div class="p-4 bg-white/[0.02] border-b border-white/5 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-[10px] font-black rounded uppercase">GET</span>
<code class="text-xs font-bold">/maps/style.json</code>
</div>
</div>
<div class="p-6">
<p class="text-sm text-slate-400 mb-6">Returns the MapLibre-compatible style configuration. Use the <code>theme</code> parameter to switch between 'light' and 'obsidian'.</p>
<h5 class="text-xs font-black uppercase tracking-widest text-slate-500 mb-4">Code Example</h5>
<div class="relative group">
<pre class="bg-slate-950 p-4 rounded-xl text-xs font-mono text-slate-300 leading-relaxed overflow-x-auto">
// Initialize MapLibre with Intaleq Style
const map = new maplibregl.Map({
container: 'map',
style: 'https://map-dashbord.intaleqapp.com/api/maps/style.json?theme=obsidian',
center: [35.9106, 31.9539], // Amman
zoom: 12
});</pre>
</div>
</div>
</div>
</div>
</div>
`,
'geocoding-api': `
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div>
<h3 class="text-3xl font-black mb-2">Geocoding API</h3>
<p class="text-slate-400">Convert addresses to coordinates (Forward) or coordinates to addresses (Reverse).</p>
</div>
<div class="endpoint-card glass rounded-2xl border-white/5 overflow-hidden">
<div class="p-4 bg-white/[0.02] border-b border-white/5 flex items-center gap-3">
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-[10px] font-black rounded uppercase">GET</span>
<code class="text-xs font-bold">/geocoding/search</code>
</div>
<div class="p-6">
<table class="w-full text-left text-xs mb-6">
<thead>
<tr class="text-slate-500 uppercase tracking-widest font-black border-b border-white/5">
<th class="pb-3">Parameter</th>
<th class="pb-3">Type</th>
<th class="pb-3">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.02]">
<tr>
<td class="py-3 font-bold text-blue-400 font-mono">q</td>
<td class="py-3 text-slate-500">string</td>
<td class="py-3">Search query (address, place, coordinates)</td>
</tr>
<tr>
<td class="py-3 font-bold text-blue-400 font-mono">limit</td>
<td class="py-3 text-slate-500">number</td>
<td class="py-3">Max results (default: 5)</td>
</tr>
</tbody>
</table>
<h5 class="text-xs font-black uppercase tracking-widest text-slate-500 mb-4">Request</h5>
<pre class="bg-slate-950 p-4 rounded-xl text-xs font-mono text-blue-400 mb-4">curl "https://map-dashbord.intaleqapp.com/api/geocoding/search?q=Amman&limit=1" \\
-H "x-api-key: YOUR_API_KEY"</pre>
</div>
</div>
</div>
`
};
container.innerHTML = content[id] || '<p class="text-slate-500">Documentation section coming soon...</p>';
lucide.createIcons();
}
};
+9
View File
@@ -0,0 +1,9 @@
const firebaseConfig = {
apiKey: "AIzaSyAwybkM9FFkF2KRM0bFNAaEPNPbMZBWFn8",
authDomain: "intaleq-map.firebaseapp.com",
projectId: "intaleq-map",
storageBucket: "intaleq-map.firebasestorage.app",
messagingSenderId: "1052695318500",
appId: "1:1052695318500:web:7913981536524a630401f1",
measurementId: "G-P5Z2YXH481"
};
+157
View File
@@ -0,0 +1,157 @@
/**
* i18n Translation Engine
*/
const i18n = {
currentLang: localStorage.getItem('intaleq_lang') || 'en',
translations: {
en: {
// Navbar
'nav-features': 'Features',
'nav-why': 'Why Us?',
'nav-pricing': 'Pricing',
'nav-launch': 'Launch Dashboard',
'lang-toggle': 'العربية',
// Hero
'hero-badge': 'Now with 3D Buildings in Jordan & Syria',
'hero-title': 'The Map API That <br> <span class="text-blue-500">Doesn\'t Break</span> The Bank.',
'hero-desc': 'Build premium location-based apps with high-fidelity vector tiles, optimized routing, and 3D buildings. 85% cheaper than Google Maps.',
'hero-cta-start': 'Start Building Free',
'hero-cta-view': 'View Comparison',
// Features
'feat-latency-title': 'Zero Latency',
'feat-latency-desc': 'Our infrastructure is optimized for MENA region, ensuring map tiles load in under 200ms.',
'feat-routing-title': 'Smart Routing',
'feat-routing-desc': 'Enterprise-grade routing engine with support for alternative paths and traffic awareness.',
'feat-geocoding-title': 'Local Geocoding',
'feat-geocoding-desc': 'Highly accurate search for local landmarks and neighborhoods in Jordan & Syria.',
// Dashboard Sidebar
'side-dashboard': 'Dashboard',
'side-playground': 'Playground',
'side-analytics': 'Analytics',
'side-billing': 'Billing',
'side-audit': 'Place Audit',
'side-docs': 'Documentation',
'side-upgrade': 'Upgrade Plan',
// Dashboard General
'welcome': 'Welcome back',
'system-status': 'System Operational',
'quota-card-title': 'Monthly Quota',
'requests-left': 'requests left',
'used-label': 'USED',
// KPI Labels
'kpi-total-req': 'Total Requests',
'kpi-success-rate': 'Success Rate',
'kpi-active-keys': 'Active Keys',
'kpi-latency': 'Avg Latency',
},
ar: {
// Navbar
'nav-features': 'المميزات',
'nav-why': 'لماذا نحن؟',
'nav-pricing': 'الأسعار',
'nav-launch': 'لوحة التحكم',
'lang-toggle': 'English',
// Hero
'hero-badge': 'الآن مع المباني ثلاثية الأبعاد في الأردن وسوريا',
'hero-title': 'واجهة خرائط برمجية <br> <span class="text-blue-500">لا ترهق</span> ميزانيتك.',
'hero-desc': 'ابنِ تطبيقات خرائط فاخرة مع خرائط مجهزة، توجيه ذكي، ومباني ثلاثية الأبعاد. أوفر بنسبة 85% من خرائط جوجل.',
'hero-cta-start': 'ابدأ مجاناً',
'hero-cta-view': 'قارن الأسعار',
// Features
'feat-latency-title': 'سرعة فائقة',
'feat-latency-desc': 'بنيتنا التحتية محسنة لمنطقة الشرق الأوسط، مما يضمن تحميل الخرائط في أقل من 200 مللي ثانية.',
'feat-routing-title': 'توجيه ذكي',
'feat-routing-desc': 'محرك توجيه من الفئة المؤسسية يدعم المسارات البديلة والوعي بحركة المرور.',
'feat-geocoding-title': 'بحث مكاني محلي',
'feat-geocoding-desc': 'دقة عالية جداً في البحث عن المعالم والأحياء في الأردن وسوريا.',
// Dashboard Sidebar
'side-dashboard': 'لوحة التحكم',
'side-playground': 'ساحة الاختبار',
'side-analytics': 'التحليلات',
'side-billing': 'الفواتير',
'side-audit': 'تدقيق الأماكن',
'side-docs': 'التوثيق',
'side-upgrade': 'ترقية الخطة',
// Dashboard General
'welcome': 'مرحباً بك مجدداً',
'system-status': 'النظام يعمل بكفاءة',
'quota-card-title': 'رصيد الاستهلاك',
'requests-left': 'طلب متبقي',
'used-label': 'مستهلك',
// KPI Labels
'kpi-total-req': 'إجمالي الطلبات',
'kpi-success-rate': 'نسبة النجاح',
'kpi-active-keys': 'المفاتيح النشطة',
'kpi-latency': 'متوسط سرعة الاستجابة',
}
},
init: () => {
i18n.apply(i18n.currentLang);
},
toggle: () => {
const nextLang = i18n.currentLang === 'en' ? 'ar' : 'en';
i18n.currentLang = nextLang;
localStorage.setItem('intaleq_lang', nextLang);
i18n.apply(nextLang);
},
apply: (lang) => {
const rtl = lang === 'ar';
document.documentElement.dir = rtl ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
// Apply font classes
if (rtl) {
document.body.style.fontFamily = "'Cairo', sans-serif";
document.body.classList.add('rtl-mode');
} else {
document.body.style.fontFamily = "'Plus Jakarta Sans', sans-serif";
document.body.classList.remove('rtl-mode');
}
// Mirror Sidebar if in dashboard
const sidebar = document.getElementById('main-sidebar');
if (sidebar) {
if (rtl) {
sidebar.classList.add('order-last', 'border-l', 'border-r-0');
sidebar.classList.remove('border-r');
} else {
sidebar.classList.remove('order-last', 'border-l');
sidebar.classList.add('border-r');
}
}
// Translate elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = i18n.translations[lang][key];
if (translation) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.placeholder = translation;
} else {
el.innerHTML = translation;
}
}
});
// Trigger icon re-render if lucide is present
if (window.lucide) lucide.createIcons();
}
};
// Initialize on load
document.addEventListener('DOMContentLoaded', i18n.init);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35 -3
View File
@@ -14,6 +14,13 @@ const playground = {
playground.renderKeySelect(keys);
playground.bindEvents();
// Watch for section changes to trigger resize
window.addEventListener('hashchange', () => {
if (window.location.hash === '#playground' && playground.map) {
setTimeout(() => playground.map.resize(), 100);
}
});
if (keys.length > 0) {
playground.selectedKey = keys[0].key;
playground.loadMap();
@@ -53,30 +60,55 @@ const playground = {
try {
// Set RTL Text Plugin
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
console.log('🌐 Loading MapLibre RTL Plugin...');
maplibregl.setRTLTextPlugin(
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
'js/plugins/mapbox-gl-rtl-text.js',
null,
true
);
}
console.log(`🛰️ Fetching Map Style for theme: ${playground.currentStyle}...`);
const styleUrl = `/api/maps/style.json?api_key=${playground.selectedKey}&theme=${playground.currentStyle}`;
const res = await fetch(styleUrl);
if (!res.ok) {
throw new Error(`Style fetch failed with status: ${res.status}`);
}
const styleData = await res.json();
console.log('🎨 Style fetched successfully, initializing MapLibre...');
playground.map = new maplibregl.Map({
container: 'map',
style: styleData,
center: [35.91, 31.95], // Amman, Jordan
zoom: 12,
attributionControl: false
attributionControl: false,
trackResize: true
});
playground.map.addControl(new maplibregl.NavigationControl(), 'top-right');
playground.map.on('load', () => {
console.log('Map loaded!');
console.log('✅ Map engine ready and tiles loading!');
// Wait slightly for container animation to finish
setTimeout(() => {
playground.map.resize();
const container = document.getElementById('map');
if (container && container.offsetWidth > 0) {
console.log('📏 Map container size verified:', container.offsetWidth, 'x', container.offsetHeight);
} else {
console.warn('⚠️ Map container has 0 width. Potential visibility issue.');
}
}, 500);
});
playground.map.on('error', (e) => {
console.error('❌ MapLibre Error Detail:', e.error || e);
if (e.error && e.error.message.includes('Style')) {
alert('Map Style Error: Please verify your API Key and Network connection.');
}
});
} catch (error) {
File diff suppressed because one or more lines are too long
+118
View File
@@ -0,0 +1,118 @@
/**
* Map Refinement (Place Audit) logic
*/
const refinement = {
state: {
candidates: []
},
init: async () => {
console.log('📍 Initializing Map Refinement Logic...');
await refinement.fetchCandidates();
},
fetchCandidates: async () => {
const headers = auth.getAuthHeader();
try {
const res = await fetch('/api/map-refinement/candidates?status=PENDING', { headers });
if (res.ok) {
refinement.state.candidates = await res.json();
refinement.renderTable();
}
} catch (error) {
console.error('Failed to fetch candidates', error);
}
},
renderTable: () => {
const tbody = document.getElementById('refinement-table-body');
if (!tbody) return;
if (refinement.state.candidates.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="py-20 text-center text-slate-500">
<div class="flex flex-col items-center gap-4">
<i data-lucide="map-pin" class="w-8 h-8 opacity-20"></i>
<p>No pending location suggestions.</p>
</div>
</td>
</tr>
`;
lucide.createIcons();
return;
}
tbody.innerHTML = refinement.state.candidates.map(c => `
<tr class="hover:bg-white/[0.02] transition-colors border-b border-white/[0.03]">
<td class="px-8 py-6">
<div class="font-bold text-white">${c.name_ar || c.name}</div>
<div class="text-[10px] text-slate-500 uppercase font-bold mt-1">${c.country}</div>
</td>
<td class="px-8 py-6">
<span class="px-2 py-1 rounded-lg bg-blue-500/10 text-blue-400 text-[10px] font-black uppercase tracking-wider">${c.category || 'General'}</span>
</td>
<td class="px-8 py-6 font-mono text-xs text-slate-400">
${parseFloat(c.latitude).toFixed(5)}, ${parseFloat(c.longitude).toFixed(5)}
</td>
<td class="px-8 py-6 text-sm text-slate-400 font-medium">
${c.submittedBy || 'System User'}
</td>
<td class="px-8 py-6 text-right space-x-2">
<button onclick="refinement.reject('${c.id}')" class="p-2.5 rounded-xl bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-all" title="Reject">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<button onclick="refinement.approve('${c.id}')" class="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 transition-all border border-emerald-500/20 shadow-lg shadow-emerald-500/10" title="Approve">
<i data-lucide="check" class="w-4 h-4"></i>
</button>
</td>
</tr>
`).join('');
lucide.createIcons();
},
approve: async (id) => {
if (!confirm('Are you sure you want to approve this location and add it to the production map?')) return;
const headers = auth.getAuthHeader();
try {
const res = await fetch(`/api/map-refinement/candidates/${id}/approve`, {
method: 'PATCH',
headers
});
if (res.ok) {
await refinement.fetchCandidates();
} else {
alert('Approval failed.');
}
} catch (error) {
console.error(error);
}
},
reject: async (id) => {
const reason = prompt('Please enter a reason for rejection:');
if (reason === null) return;
const headers = auth.getAuthHeader();
try {
const res = await fetch(`/api/map-refinement/candidates/${id}/reject`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ reason })
});
if (res.ok) {
await refinement.fetchCandidates();
} else {
alert('Rejection failed.');
}
} catch (error) {
console.error(error);
}
}
};
+9
View File
@@ -100,8 +100,17 @@ services:
- LOCATION_SERVER_API_KEY=${LOCATION_SERVER_API_KEY}
- GRAPH_HOPPER_URL=${GRAPH_HOPPER_URL}
- TILE_SERVER_URL=${TILE_SERVER_URL}
- FIREBASE_SERVICE_ACCOUNT_PATH=${FIREBASE_SERVICE_ACCOUNT_PATH}
- PAYMOB_API_KEY=${PAYMOB_API_KEY}
- PAYMOB_HMAC_SECRET=${PAYMOB_HMAC_SECRET}
- PAYMOB_INTEGRATION_ID=${PAYMOB_INTEGRATION_ID}
- PAYMOB_IFRAME_ID=${PAYMOB_IFRAME_ID}
- BINANCE_PAY_API_KEY=${BINANCE_PAY_API_KEY}
- BINANCE_PAY_SECRET_KEY=${BINANCE_PAY_SECRET_KEY}
- BINANCE_PAY_MERCHANT_ID=${BINANCE_PAY_MERCHANT_ID}
volumes:
- .:/data
- ./infrastructure/secrets:/secrets:ro
depends_on:
db:
condition: service_healthy
+1 -1
View File
@@ -4,7 +4,7 @@ FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
# Copy static assets into nginx
COPY apps/dashboard/index.html /usr/share/nginx/html/
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/
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "intaleq-map",
"private_key_id": "182068406bf26b2c6d19d5a0a937a7dcee1610b0",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmeAP86jgMSjyT\n8onkgCs2gIFVLAZ2UeC/XTuwvTPfLBxU0w2LDumTC3OqygrSa/Y6WvE00RvBbLWy\nejclvA5GHseX5iwt1Y5JdARPFrD6OsLNi+tlqkHRKCSMqe5Zr/CrJv/7/Ct6dcoE\nvqvsEcEHpZNov9fcA5HC7aoHefwK5JcSbwHi1o9nZlsAbl1UmU6DCD218uUdTs4C\nNShAuZq5/V+WxQzJt8Qj3I1dQypJFpzJG+MEoDhjNs2Ss90wPGfBIP/lwkJBO8H9\nQpDDzAHU6VwBo/MWDqGs0Zy1cq2Dr2DSTdwOmYevMhrH0lks+CEl/XrLWYV1HDja\nkbjGPO2DAgMBAAECggEAMNFXNul9+cx3zHbhko87mA3cV2g97i4lxyM+k49gP3Oe\nhLE3+y6rd0RDufeWF0BbJb1BvohUssIOMsIEkG+nLl8ytBBDZ2oG+7QhfYc28aok\nvVlYAW7xBhbUtx7/p+vGtNpL+tpNc2Ej66Ff1V9lXfNKqDOKy8XNyFaDX5YNN1kR\nSx6BgrGcQtm/TXBTbennSKocw65EMrpRrK91R5rtQXFyAfSD/YiWX9s0ERIz0W4j\nYjVq2k/ERTnwPtoX5iU1OGQHQUnI0sNEsACojBEBjrj74dI3QyfpUtbXoT3f5i6T\nyNwsGApavXMZNrc1hjoMk5SjQPuptPOgbheNwAQIoQKBgQD+hCHLh02K2Z4lLVdS\nVOv3nx/isBb87mu6sUs8TDLeC11lRynTcBlXWGUeUgBaA5SIFuuTluA490Em3Roe\nzdLiMoCUWY9v/8NoWSqP4NfnBRpgDSDS61WcWFr20MH+3mY05mAKZmkD0MYQYnny\nsDec44vLEZ8M+5WTlwW3AMrmSwKBgQDnz/4gBYq70D5qSUSKKhqi7ltBIqUjlQqb\nGSB4pDrRQXC93zdieRGcLivdBZzv/z+HubwciUC0yl/YVp9WAM0dl+XgQX4iBaU1\ni/KrHzDIdWZpaCxbx77zNPaCod5bu7OT3cRYfqbbIs4GTizpZJobe9jxA5+k+vVN\nyMQbmMjyqQKBgB3XWC8I2iqhgU1Sl55rno8V9SMbClb1jWQCTZPwSzaFlpm9UkYc\nKpx3HMQFUU08hjm+ljhjxD5pnxXzbpCWCVfEHBdIuOykzEB70+Wysx2/F2yTnXmd\nZhhCs8ekilpbsRgaur/9aeqsm8xn/2xZBOw8MbPJiSB4jv0TA/SE/0UpAoGBAI40\n3mSugakt2txBDjbkFWsWZgzTQBNP/y2egHkB5sMwLgWMxeE2/EKfYHE6XEgugb61\nhlzLJlLCQ4Hnwd90pmAdKvwjsUkVxX/P1pJ7k+Wlf6nyKiQzqURxnTgOLOaBg2yW\ngzj2mU2dUF413v9eo9twYmmYG3uOKKYLH6L+W2fhAoGAPczHaVYKYpQEJcKpaWxO\nqCVjMqdzEOulrt1+SPhZiiRyoMZAWcn+5Vh+O6iIzONQYyMeFYMGythD6by6YTi4\nZugiG/m6prAA5Q0xDuKqs3/M4v7XAo0/MdWEbBv2h1IEu2y5B4yHQNvUrx+njWjQ\nSJWxfKfq/K2y4+KH6zix/PU=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@intaleq-map.iam.gserviceaccount.com",
"client_id": "114583905725353606308",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40intaleq-map.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}