ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات

ميزة: إضافة خدمة تحليلات لتكامل Google Analytics

اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية

إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم

ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين

إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
This commit is contained in:
Your Name
2026-03-07 11:14:05 +02:00
parent cfbcc8bd79
commit 0ad2ba0f02
73 changed files with 4696 additions and 462 deletions

View File

@@ -0,0 +1,67 @@
type AnalyticsValue = string | number | boolean | undefined;
declare global {
interface Window {
dataLayer: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
const GA_MEASUREMENT_ID = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim();
let initialized = false;
function ensureGtagShim() {
window.dataLayer = window.dataLayer || [];
window.gtag =
window.gtag ||
function gtag(...args: unknown[]) {
window.dataLayer.push(args);
};
}
function loadGaScript() {
if (!GA_MEASUREMENT_ID) return;
const existing = document.querySelector<HTMLScriptElement>(
`script[data-ga4-id="${GA_MEASUREMENT_ID}"]`
);
if (existing) return;
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
script.setAttribute('data-ga4-id', GA_MEASUREMENT_ID);
document.head.appendChild(script);
}
export function initAnalytics() {
if (initialized || !GA_MEASUREMENT_ID || typeof window === 'undefined') return;
ensureGtagShim();
loadGaScript();
window.gtag?.('js', new Date());
window.gtag?.('config', GA_MEASUREMENT_ID, { send_page_view: false });
initialized = true;
}
export function trackPageView(path: string) {
if (!initialized || !window.gtag) return;
window.gtag('event', 'page_view', {
page_path: path,
page_location: `${window.location.origin}${path}`,
page_title: document.title,
});
}
export function trackEvent(
eventName: string,
params: Record<string, AnalyticsValue> = {}
) {
if (!initialized || !window.gtag) return;
window.gtag('event', eventName, params);
}
export function analyticsEnabled() {
return Boolean(GA_MEASUREMENT_ID);
}

View File

@@ -103,14 +103,13 @@ describe('API Service — Endpoint Format Tests', () => {
// PDF Tools endpoints
// ----------------------------------------------------------
describe('PDF Tools API', () => {
it('Merge: should POST multiple files to /api/pdf-tools/merge', () => {
// MergePdf.tsx uses fetch('/api/pdf-tools/merge') directly, not api.post
it('Merge: should POST multiple files to /pdf-tools/merge', () => {
const formData = new FormData();
formData.append('files', new Blob(['%PDF-1.4']), 'a.pdf');
formData.append('files', new Blob(['%PDF-1.4']), 'b.pdf');
const url = '/api/pdf-tools/merge';
const url = '/pdf-tools/merge';
expect(url).toBe('/api/pdf-tools/merge');
expect(url).toBe('/pdf-tools/merge');
expect(formData.getAll('files').length).toBe(2);
});
@@ -159,14 +158,13 @@ describe('API Service — Endpoint Format Tests', () => {
expect(formData.get('format')).toBe('png');
});
it('Images to PDF: should POST multiple files to /api/pdf-tools/images-to-pdf', () => {
// ImagesToPdf.tsx uses fetch('/api/pdf-tools/images-to-pdf') directly
it('Images to PDF: should POST multiple files to /pdf-tools/images-to-pdf', () => {
const formData = new FormData();
formData.append('files', new Blob(['\x89PNG']), 'img1.png');
formData.append('files', new Blob(['\x89PNG']), 'img2.png');
const url = '/api/pdf-tools/images-to-pdf';
const url = '/pdf-tools/images-to-pdf';
expect(url).toBe('/api/pdf-tools/images-to-pdf');
expect(url).toBe('/pdf-tools/images-to-pdf');
expect(formData.getAll('files').length).toBe(2);
});
@@ -264,9 +262,8 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => {
AddPageNumbers: { method: 'POST', endpoint: '/pdf-tools/page-numbers', fieldName: 'file' },
PdfToImages: { method: 'POST', endpoint: '/pdf-tools/pdf-to-images', fieldName: 'file' },
VideoToGif: { method: 'POST', endpoint: '/video/to-gif', fieldName: 'file' },
// Multi-file tools use fetch() directly with full path:
MergePdf: { method: 'POST', endpoint: '/api/pdf-tools/merge', fieldName: 'files' },
ImagesToPdf: { method: 'POST', endpoint: '/api/pdf-tools/images-to-pdf', fieldName: 'files' },
MergePdf: { method: 'POST', endpoint: '/pdf-tools/merge', fieldName: 'files' },
ImagesToPdf: { method: 'POST', endpoint: '/pdf-tools/images-to-pdf', fieldName: 'files' },
};
Object.entries(toolEndpointMap).forEach(([tool, config]) => {
@@ -276,4 +273,4 @@ describe('Frontend Tool → Backend Endpoint Mapping', () => {
expect(config.fieldName).toMatch(/^(file|files)$/);
});
});
});
});

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 120000, // 2 minute timeout for file processing
withCredentials: true,
headers: {
Accept: 'application/json',
},
@@ -77,6 +78,38 @@ export interface TaskResult {
total_pages?: number;
}
export interface AuthUser {
id: number;
email: string;
plan: string;
created_at: string;
}
interface AuthResponse {
message: string;
user: AuthUser;
}
interface AuthSessionResponse {
authenticated: boolean;
user: AuthUser | null;
}
interface HistoryResponse {
items: HistoryEntry[];
}
export interface HistoryEntry {
id: number;
tool: string;
original_filename: string | null;
output_filename: string | null;
status: 'completed' | 'failed' | string;
download_url: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
/**
* Upload a file and start a processing task.
*/
@@ -108,6 +141,87 @@ export async function uploadFile(
return response.data;
}
/**
* Upload multiple files and start a processing task.
*/
export async function uploadFiles(
endpoint: string,
files: File[],
fileField = 'files',
extraData?: Record<string, string>,
onProgress?: (percent: number) => void
): Promise<TaskResponse> {
const formData = new FormData();
files.forEach((file) => formData.append(fileField, file));
if (extraData) {
Object.entries(extraData).forEach(([key, value]) => {
formData.append(key, value);
});
}
const response = await api.post<TaskResponse>(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
if (event.total && onProgress) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
},
});
return response.data;
}
/**
* Start a task endpoint that does not require file upload.
*/
export async function startTask(endpoint: string): Promise<TaskResponse> {
const response = await api.post<TaskResponse>(endpoint);
return response.data;
}
/**
* Create a new account and return the authenticated user.
*/
export async function registerUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/register', { email, password });
return response.data.user;
}
/**
* Sign in and return the authenticated user.
*/
export async function loginUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/login', { email, password });
return response.data.user;
}
/**
* End the current authenticated session.
*/
export async function logoutUser(): Promise<void> {
await api.post('/auth/logout');
}
/**
* Return the current authenticated user, if any.
*/
export async function getCurrentUser(): Promise<AuthUser | null> {
const response = await api.get<AuthSessionResponse>('/auth/me');
return response.data.user;
}
/**
* Return recent authenticated file history.
*/
export async function getHistory(limit = 50): Promise<HistoryEntry[]> {
const response = await api.get<HistoryResponse>('/history', {
params: { limit },
});
return response.data.items;
}
/**
* Poll task status.
*/
@@ -128,4 +242,63 @@ export async function checkHealth(): Promise<boolean> {
}
}
// --- Account / Usage / API Keys ---
export interface UsageSummary {
plan: string;
period_month: string;
ads_enabled: boolean;
history_limit: number;
file_limits_mb: {
pdf: number;
word: number;
image: number;
video: number;
homepageSmartUpload: number;
};
web_quota: { used: number; limit: number | null };
api_quota: { used: number; limit: number | null };
}
export interface ApiKey {
id: number;
name: string;
key_prefix: string;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
raw_key?: string; // only present on creation
}
/**
* Return the current user's plan, quota, and file-limit summary.
*/
export async function getUsage(): Promise<UsageSummary> {
const response = await api.get<UsageSummary>('/account/usage');
return response.data;
}
/**
* Return all API keys for the authenticated pro user.
*/
export async function getApiKeys(): Promise<ApiKey[]> {
const response = await api.get<{ items: ApiKey[] }>('/account/api-keys');
return response.data.items;
}
/**
* Create a new API key with the given name. Returns the key including raw_key once.
*/
export async function createApiKey(name: string): Promise<ApiKey> {
const response = await api.post<ApiKey>('/account/api-keys', { name });
return response.data;
}
/**
* Revoke one API key by id.
*/
export async function revokeApiKey(keyId: number): Promise<void> {
await api.delete(`/account/api-keys/${keyId}`);
}
export default api;