ميزة: تحديث صفحات الخصوصية والشروط مع تاريخ آخر تحديث ثابت وفترة احتفاظ ديناميكية بالملفات
ميزة: إضافة خدمة تحليلات لتكامل Google Analytics اختبار: تحديث اختبارات خدمة واجهة برمجة التطبيقات (API) لتعكس تغييرات نقاط النهاية إصلاح: تعديل خدمة واجهة برمجة التطبيقات (API) لدعم تحميل ملفات متعددة ومصادقة المستخدم ميزة: تطبيق مخزن مصادقة باستخدام Zustand لإدارة المستخدمين إصلاح: تحسين إعدادات Nginx لتعزيز الأمان ودعم التحليلات
This commit is contained in:
67
frontend/src/services/analytics.ts
Normal file
67
frontend/src/services/analytics.ts
Normal 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);
|
||||
}
|
||||
@@ -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)$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user