2026-04-15-4
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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
@@ -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 © 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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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
Vendored
+12
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+83
File diff suppressed because one or more lines are too long
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user