From 314f847ece475008a2b00522de51f8c18a3bef8b Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:22:48 +0200 Subject: [PATCH] fix: Add scrollable container to ToolSelectorModal for small screens - Add max-h-[90vh] and flex-col to modal content container - Wrap tools grid in max-h-[50vh] overflow-y-auto container - Add overscroll-contain for smooth scroll behavior on mobile - Fixes issue where 21 PDF tools overflow viewport on small screens --- .../prompts/generate-backend-tests.prompt.md | 43 ++ backend/app/extensions.py | 2 +- backend/app/routes/account.py | 83 ++- backend/app/routes/barcode.py | 2 +- backend/app/routes/compress.py | 2 +- backend/app/routes/compress_image.py | 2 +- backend/app/routes/convert.py | 4 +- backend/app/routes/download.py | 7 + backend/app/routes/flowchart.py | 4 +- backend/app/routes/html_to_pdf.py | 2 +- backend/app/routes/image.py | 6 +- backend/app/routes/image_extra.py | 4 +- backend/app/routes/ocr.py | 4 +- backend/app/routes/pdf_ai.py | 18 +- backend/app/routes/pdf_convert.py | 8 +- backend/app/routes/pdf_editor.py | 2 +- backend/app/routes/pdf_extra.py | 8 +- backend/app/routes/pdf_to_excel.py | 2 +- backend/app/routes/pdf_tools.py | 24 +- backend/app/routes/qrcode.py | 2 +- backend/app/routes/removebg.py | 2 +- backend/app/routes/video.py | 2 +- backend/app/services/account_service.py | 51 +- backend/app/services/credit_config.py | 122 ++++ backend/app/services/credit_service.py | 268 ++++++++ backend/app/services/guest_budget_service.py | 86 +++ backend/app/services/policy_service.py | 106 ++- .../app/services/translation_guardrails.py | 151 +++++ backend/app/tasks/pdf_ai_tasks.py | 23 +- backend/tests/test_config.py | 3 +- backend/tests/test_download.py | 35 +- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- frontend/src/App.tsx | 128 +--- .../src/components/shared/DownloadButton.tsx | 56 +- .../shared/SignUpToDownloadModal.tsx | 164 +++++ .../components/shared/ToolSelectorModal.tsx | 42 +- frontend/src/config/routes.test.ts | 17 +- frontend/src/config/routes.ts | 67 +- frontend/src/config/toolManifest.test.ts | 114 ++++ frontend/src/config/toolManifest.ts | 601 ++++++++++++++++++ frontend/src/i18n/ar.json | 25 + frontend/src/i18n/en.json | 25 + frontend/src/i18n/fr.json | 25 + frontend/src/pages/AccountPage.tsx | 31 +- frontend/src/pages/HomePage.tsx | 67 +- frontend/src/pages/PricingPage.tsx | 2 +- frontend/src/services/api.ts | 26 +- frontend/src/utils/seo.ts | 31 +- 49 files changed, 2142 insertions(+), 361 deletions(-) create mode 100644 .github/prompts/generate-backend-tests.prompt.md create mode 100644 backend/app/services/credit_config.py create mode 100644 backend/app/services/credit_service.py create mode 100644 backend/app/services/guest_budget_service.py create mode 100644 backend/app/services/translation_guardrails.py create mode 100644 frontend/src/components/shared/SignUpToDownloadModal.tsx create mode 100644 frontend/src/config/toolManifest.test.ts create mode 100644 frontend/src/config/toolManifest.ts diff --git a/.github/prompts/generate-backend-tests.prompt.md b/.github/prompts/generate-backend-tests.prompt.md new file mode 100644 index 0000000..e0b4df3 --- /dev/null +++ b/.github/prompts/generate-backend-tests.prompt.md @@ -0,0 +1,43 @@ +--- +description: "Generate a complete pytest test file for the selected Flask route or service code, following project conventions in backend/tests/" +name: "Generate Backend Tests" +argument-hint: "Optional: additional context or specific scenarios to cover" +agent: "agent" +tools: ["read_file", "grep_search", "file_search"] +--- + +Generate a complete pytest test file for the following Flask backend code (shown in `#selection`): + +``` +#selection +``` + +## Rules + +Follow the conventions used in [backend/tests/](../../backend/tests/): + +- **Class-based**: Wrap all test methods in a `class Test` (e.g., `class TestCompressRoutes`). +- **Fixtures**: Use `client` for HTTP tests and `app` only when patching `app.config`. Both come from [backend/tests/conftest.py](../../backend/tests/conftest.py). Do **not** redefine fixtures. +- **CSRF**: The `CSRFTestClient` in conftest injects `X-CSRF-Token` automatically for mutating requests — no manual handling needed. +- **Mocking**: Use `unittest.mock.patch` and `MagicMock`. Patch at the service boundary (e.g., `@patch("app.routes.compress.compress_service.run")`) not at the stdlib level. +- **Assertions**: Always assert `response.status_code` first, then check `response.get_json()` contents. +- **Naming**: Test method names must be descriptive, e.g., `test_compress_returns_200_on_valid_pdf`, `test_compress_returns_400_when_no_file_provided`. + +## Required Coverage + +Include tests for: +1. **Happy path** — valid input, expected 2xx response and payload shape. +2. **Missing / invalid input** — 400 responses with an `error` key in JSON. +3. **Unauthenticated access** — 401 when a login-protected route is hit without a session (register + logout first if needed). +4. **Service failure** — mock the underlying service to raise an exception and assert the route returns 500. +5. **Edge cases** — any domain-specific boundaries visible in the selected code (e.g., file size limits, unsupported MIME types, quota exceeded). + +## Output Format + +Output a single, ready-to-save Python file. +- First line: `"""Tests for ."""` +- Then imports, then the test class(es). +- Suggest the save path as a comment at the top: `# Save as: backend/tests/test_.py` +- Do **not** add `if __name__ == "__main__"` blocks. + +{{argument}} diff --git a/backend/app/extensions.py b/backend/app/extensions.py index f97776d..2ff2230 100644 --- a/backend/app/extensions.py +++ b/backend/app/extensions.py @@ -70,7 +70,7 @@ def init_celery(app): "app.tasks.pdf_to_excel_tasks.*": {"queue": "pdf_tools"}, "app.tasks.qrcode_tasks.*": {"queue": "default"}, "app.tasks.html_to_pdf_tasks.*": {"queue": "convert"}, - "app.tasks.pdf_ai_tasks.*": {"queue": "default"}, + "app.tasks.pdf_ai_tasks.*": {"queue": "ai_heavy"}, "app.tasks.pdf_convert_tasks.*": {"queue": "convert"}, "app.tasks.pdf_extra_tasks.*": {"queue": "pdf_tools"}, "app.tasks.image_extra_tasks.*": {"queue": "image"}, diff --git a/backend/app/routes/account.py b/backend/app/routes/account.py index c912844..7ffb689 100644 --- a/backend/app/routes/account.py +++ b/backend/app/routes/account.py @@ -6,15 +6,24 @@ from app.extensions import limiter from app.services.account_service import ( create_api_key, get_user_by_id, + has_task_access, list_api_keys, + record_usage_event, revoke_api_key, ) from app.services.policy_service import get_usage_summary_for_user +from app.services.credit_config import ( + get_all_tool_costs, + get_credits_for_plan, + get_tool_credit_cost, + CREDIT_WINDOW_DAYS, +) +from app.services.credit_service import deduct_credits, get_credit_summary from app.services.stripe_service import ( is_stripe_configured, get_stripe_price_id, ) -from app.utils.auth import get_current_user_id +from app.utils.auth import get_current_user_id, has_session_task_access import stripe import logging @@ -38,6 +47,19 @@ def get_usage_route(): return jsonify(get_usage_summary_for_user(user_id, user["plan"])), 200 +@account_bp.route("/credit-info", methods=["GET"]) +@limiter.limit("60/hour") +def get_credit_info_route(): + """Return public credit/pricing info (no auth required).""" + return jsonify({ + "plans": { + "free": {"credits": get_credits_for_plan("free"), "window_days": CREDIT_WINDOW_DAYS}, + "pro": {"credits": get_credits_for_plan("pro"), "window_days": CREDIT_WINDOW_DAYS}, + }, + "tool_costs": get_all_tool_costs(), + }), 200 + + @account_bp.route("/subscription", methods=["GET"]) @limiter.limit("60/hour") def get_subscription_status(): @@ -159,3 +181,62 @@ def revoke_api_key_route(key_id: int): return jsonify({"error": "API key not found or already revoked."}), 404 return jsonify({"message": "API key revoked."}), 200 + + +@account_bp.route("/claim-task", methods=["POST"]) +@limiter.limit("60/hour") +def claim_task_route(): + """Adopt an anonymous task into the authenticated user's history. + + Called after a guest signs up or logs in to record the previously + processed task in their account and deduct credits. + """ + user_id = get_current_user_id() + if user_id is None: + return jsonify({"error": "Authentication required."}), 401 + + data = request.get_json(silent=True) or {} + task_id = str(data.get("task_id", "")).strip() + tool = str(data.get("tool", "")).strip() + + if not task_id or not tool: + return jsonify({"error": "task_id and tool are required."}), 400 + + # Verify this task belongs to the caller's session + if not has_session_task_access(task_id): + return jsonify({"error": "Task not found in your session."}), 403 + + # Skip if already claimed (idempotent) + if has_task_access(user_id, "web", task_id): + summary = get_credit_summary(user_id, "free") + return jsonify({"claimed": True, "credits": summary}), 200 + + user = get_user_by_id(user_id) + if user is None: + return jsonify({"error": "User not found."}), 404 + + plan = user.get("plan", "free") + cost = get_tool_credit_cost(tool) + + # Deduct credits + try: + deduct_credits(user_id, plan, tool) + except ValueError: + return jsonify({ + "error": "Insufficient credits to claim this file.", + "credits_required": cost, + }), 429 + + # Record usage event so the task appears in history + record_usage_event( + user_id=user_id, + source="web", + tool=tool, + task_id=task_id, + event_type="accepted", + api_key_id=None, + cost_points=cost, + ) + + summary = get_credit_summary(user_id, plan) + return jsonify({"claimed": True, "credits": summary}), 200 diff --git a/backend/app/routes/barcode.py b/backend/app/routes/barcode.py index f6202d3..364ca8d 100644 --- a/backend/app/routes/barcode.py +++ b/backend/app/routes/barcode.py @@ -52,7 +52,7 @@ def generate_barcode_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="barcode") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/compress.py b/backend/app/routes/compress.py index 3020103..7adc8f6 100644 --- a/backend/app/routes/compress.py +++ b/backend/app/routes/compress.py @@ -38,7 +38,7 @@ def compress_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="compress-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/compress_image.py b/backend/app/routes/compress_image.py index 72b1b09..3994d35 100644 --- a/backend/app/routes/compress_image.py +++ b/backend/app/routes/compress_image.py @@ -43,7 +43,7 @@ def compress_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="compress-image") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/convert.py b/backend/app/routes/convert.py index 2d55c8e..86e240e 100644 --- a/backend/app/routes/convert.py +++ b/backend/app/routes/convert.py @@ -33,7 +33,7 @@ def pdf_to_word_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-to-word") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -75,7 +75,7 @@ def word_to_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="word-to-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/download.py b/backend/app/routes/download.py index cdc7452..a38115a 100644 --- a/backend/app/routes/download.py +++ b/backend/app/routes/download.py @@ -34,6 +34,13 @@ def download_file(task_id: str, filename: str): assert_api_task_access(actor, task_id) else: actor = resolve_web_actor() + # Download gate: anonymous users must register before downloading + if actor.actor_type == "anonymous": + return ( + {"error": "signup_required", + "message": "Create a free account to download your file."}, + 401, + ) assert_web_task_access(actor, task_id) except PolicyError as exc: abort(exc.status_code, exc.message) diff --git a/backend/app/routes/flowchart.py b/backend/app/routes/flowchart.py index 735c252..2bdc662 100644 --- a/backend/app/routes/flowchart.py +++ b/backend/app/routes/flowchart.py @@ -39,7 +39,7 @@ def extract_flowchart_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-flowchart") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -75,7 +75,7 @@ def extract_sample_flowchart_route(): """ actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-flowchart-sample") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/html_to_pdf.py b/backend/app/routes/html_to_pdf.py index 3de231c..2417116 100644 --- a/backend/app/routes/html_to_pdf.py +++ b/backend/app/routes/html_to_pdf.py @@ -34,7 +34,7 @@ def html_to_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="html-to-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/image.py b/backend/app/routes/image.py index fca3aa1..14a1aa0 100644 --- a/backend/app/routes/image.py +++ b/backend/app/routes/image.py @@ -54,7 +54,7 @@ def convert_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="image-convert") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -127,7 +127,7 @@ def resize_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="image-resize") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -180,7 +180,7 @@ def convert_image_to_svg_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="image-to-svg") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/image_extra.py b/backend/app/routes/image_extra.py index 456476e..3a0249b 100644 --- a/backend/app/routes/image_extra.py +++ b/backend/app/routes/image_extra.py @@ -54,7 +54,7 @@ def crop_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="image-crop") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -120,7 +120,7 @@ def rotate_flip_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="image-rotate-flip") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/ocr.py b/backend/app/routes/ocr.py index 7162322..87deb74 100644 --- a/backend/app/routes/ocr.py +++ b/backend/app/routes/ocr.py @@ -52,7 +52,7 @@ def ocr_image_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="ocr-image") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -102,7 +102,7 @@ def ocr_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="ocr-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_ai.py b/backend/app/routes/pdf_ai.py index 0ef1da4..b084148 100644 --- a/backend/app/routes/pdf_ai.py +++ b/backend/app/routes/pdf_ai.py @@ -11,6 +11,10 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.translation_guardrails import ( + check_page_admission, + TranslationAdmissionError, +) from app.utils.file_validator import FileValidationError from app.utils.sanitizer import generate_safe_path from app.tasks.pdf_ai_tasks import ( @@ -48,7 +52,7 @@ def chat_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="chat-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -104,7 +108,7 @@ def summarize_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="summarize-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -161,7 +165,7 @@ def translate_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="translate-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -175,6 +179,12 @@ def translate_pdf_route(): task_id, input_path = generate_safe_path(ext, folder_type="upload") file.save(input_path) + # ── Page-count admission guard ── + try: + page_count = check_page_admission(input_path, actor.plan) + except TranslationAdmissionError as e: + return jsonify({"error": e.message}), e.status_code + task = translate_pdf_task.delay( input_path, task_id, @@ -213,7 +223,7 @@ def extract_tables_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="extract-tables") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_convert.py b/backend/app/routes/pdf_convert.py index 277b61c..463f2c9 100644 --- a/backend/app/routes/pdf_convert.py +++ b/backend/app/routes/pdf_convert.py @@ -40,7 +40,7 @@ def pdf_to_pptx_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-to-pptx") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -77,7 +77,7 @@ def excel_to_pdf_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="excel-to-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -116,7 +116,7 @@ def pptx_to_pdf_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pptx-to-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -167,7 +167,7 @@ def sign_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="sign-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_editor.py b/backend/app/routes/pdf_editor.py index 4976fa0..240f099 100644 --- a/backend/app/routes/pdf_editor.py +++ b/backend/app/routes/pdf_editor.py @@ -54,7 +54,7 @@ def edit_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-edit") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_extra.py b/backend/app/routes/pdf_extra.py index 881bb2f..ee2516a 100644 --- a/backend/app/routes/pdf_extra.py +++ b/backend/app/routes/pdf_extra.py @@ -41,7 +41,7 @@ def crop_pdf_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="crop-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -89,7 +89,7 @@ def flatten_pdf_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="flatten-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -126,7 +126,7 @@ def repair_pdf_route(): file = request.files["file"] actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="repair-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -184,7 +184,7 @@ def edit_metadata_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="edit-metadata") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_to_excel.py b/backend/app/routes/pdf_to_excel.py index ea571ed..e7ddff7 100644 --- a/backend/app/routes/pdf_to_excel.py +++ b/backend/app/routes/pdf_to_excel.py @@ -34,7 +34,7 @@ def pdf_to_excel_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-to-excel") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/pdf_tools.py b/backend/app/routes/pdf_tools.py index f6fb1d6..60c773f 100644 --- a/backend/app/routes/pdf_tools.py +++ b/backend/app/routes/pdf_tools.py @@ -56,7 +56,7 @@ def merge_pdfs_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="merge-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -123,7 +123,7 @@ def split_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="split-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -183,7 +183,7 @@ def rotate_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="rotate-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -246,7 +246,7 @@ def add_page_numbers_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="page-numbers") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -304,7 +304,7 @@ def pdf_to_images_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="pdf-to-images") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -353,7 +353,7 @@ def images_to_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="images-to-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -424,7 +424,7 @@ def watermark_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="watermark-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -480,7 +480,7 @@ def protect_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="protect-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -532,7 +532,7 @@ def unlock_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="unlock-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -579,7 +579,7 @@ def remove_watermark_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="remove-watermark") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -638,7 +638,7 @@ def reorder_pdf_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="reorder-pdf") except PolicyError as e: return jsonify({"error": e.message}), e.status_code @@ -690,7 +690,7 @@ def extract_pages_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="extract-pages") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/qrcode.py b/backend/app/routes/qrcode.py index 1c1221d..f6c64db 100644 --- a/backend/app/routes/qrcode.py +++ b/backend/app/routes/qrcode.py @@ -45,7 +45,7 @@ def generate_qr_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="qr-code") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/removebg.py b/backend/app/routes/removebg.py index 49634b8..e9500cd 100644 --- a/backend/app/routes/removebg.py +++ b/backend/app/routes/removebg.py @@ -38,7 +38,7 @@ def remove_bg_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="remove-bg") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/routes/video.py b/backend/app/routes/video.py index 0c6275f..c19d306 100644 --- a/backend/app/routes/video.py +++ b/backend/app/routes/video.py @@ -59,7 +59,7 @@ def video_to_gif_route(): actor = resolve_web_actor() try: - assert_quota_available(actor) + assert_quota_available(actor, tool="video-frames") except PolicyError as e: return jsonify({"error": e.message}), e.status_code diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py index bd19c27..9a1f7cd 100644 --- a/backend/app/services/account_service.py +++ b/backend/app/services/account_service.py @@ -228,6 +228,30 @@ def _init_postgres_tables(conn): ON file_events(created_at DESC) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_credit_windows ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE, + window_start_at TEXT NOT NULL, + window_end_at TEXT NOT NULL, + credits_allocated INTEGER NOT NULL, + credits_used INTEGER NOT NULL DEFAULT 0, + plan TEXT NOT NULL DEFAULT 'free', + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_ucw_user + ON user_credit_windows(user_id) + """) + + # Add cost_points column to usage_events if missing + if not _column_exists(conn, "usage_events", "cost_points"): + cursor.execute( + "ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1" + ) + def _init_sqlite_tables(conn): conn.executescript( @@ -316,6 +340,21 @@ def _init_sqlite_tables(conn): CREATE INDEX IF NOT EXISTS idx_file_events_created ON file_events(created_at DESC); + + CREATE TABLE IF NOT EXISTS user_credit_windows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + window_start_at TEXT NOT NULL, + window_end_at TEXT NOT NULL, + credits_allocated INTEGER NOT NULL, + credits_used INTEGER NOT NULL DEFAULT 0, + plan TEXT NOT NULL DEFAULT 'free', + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_ucw_user + ON user_credit_windows(user_id); """ ) @@ -325,6 +364,8 @@ def _init_sqlite_tables(conn): conn.execute("ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''") if not _column_exists(conn, "users", "role"): conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'") + if not _column_exists(conn, "usage_events", "cost_points"): + conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1") def create_user(email: str, password: str) -> dict: @@ -842,6 +883,7 @@ def record_usage_event( task_id: str, event_type: str, api_key_id: int | None = None, + cost_points: int = 1, ): if user_id is None: return @@ -851,17 +893,17 @@ def record_usage_event( """ INSERT INTO usage_events ( user_id, api_key_id, source, tool, task_id, - event_type, created_at, period_month + event_type, created_at, period_month, cost_points ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """ if is_postgres() else """ INSERT INTO usage_events ( user_id, api_key_id, source, tool, task_id, - event_type, created_at, period_month + event_type, created_at, period_month, cost_points ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """ ) execute_query( @@ -876,6 +918,7 @@ def record_usage_event( event_type, _utc_now(), get_current_period_month(), + cost_points, ), ) diff --git a/backend/app/services/credit_config.py b/backend/app/services/credit_config.py new file mode 100644 index 0000000..b24fca5 --- /dev/null +++ b/backend/app/services/credit_config.py @@ -0,0 +1,122 @@ +"""Unified Credit System — tool cost registry and credit constants. + +Every tool has a credit cost. Lighter tools cost 1 credit, heavier +server-side conversions cost 2, CPU/ML-intensive tools cost 3, +and AI-powered tools cost 5+. + +This module is the single source of truth for all credit-related +constants consumed by policy_service, credit_service, and the +frontend config endpoint. +""" + +import os + +# ── Credit allocations per rolling 30-day window ──────────────── +FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50")) +PRO_CREDITS_PER_WINDOW = int(os.getenv("PRO_CREDITS_PER_WINDOW", "500")) +CREDIT_WINDOW_DAYS = int(os.getenv("CREDIT_WINDOW_DAYS", "30")) + +# ── Guest demo budget (anonymous, pre-registration) ──────────── +GUEST_DEMO_BUDGET = int(os.getenv("GUEST_DEMO_BUDGET", "3")) +GUEST_DEMO_TTL_HOURS = int(os.getenv("GUEST_DEMO_TTL_HOURS", "24")) + +# ── API quota (Pro only, per rolling window) ──────────────────── +PRO_API_CREDITS_PER_WINDOW = int(os.getenv("PRO_API_CREDITS_PER_WINDOW", "1000")) + +# ── Cost tiers ────────────────────────────────────────────────── +TIER_LIGHT = 1 # Fast, in-memory or trivial server ops +TIER_MEDIUM = 2 # Server-side conversion (LibreOffice, Ghostscript, etc.) +TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression) +TIER_AI = 5 # AI-powered tools (LLM API calls) + +# ── Per-tool credit costs ─────────────────────────────────────── +# Keys match the `tool` parameter passed to record_usage_event / routes. +TOOL_CREDIT_COSTS: dict[str, int] = { + # ─── PDF Core (light operations) ──────────────────────────── + "merge-pdf": TIER_LIGHT, + "split-pdf": TIER_LIGHT, + "rotate-pdf": TIER_LIGHT, + "reorder-pdf": TIER_LIGHT, + "extract-pages": TIER_LIGHT, + "page-numbers": TIER_LIGHT, + "watermark-pdf": TIER_LIGHT, + "protect-pdf": TIER_LIGHT, + "unlock-pdf": TIER_LIGHT, + "flatten-pdf": TIER_LIGHT, + "repair-pdf": TIER_LIGHT, + "pdf-metadata": TIER_LIGHT, + "crop-pdf": TIER_LIGHT, + "sign-pdf": TIER_LIGHT, + "pdf-to-images": TIER_LIGHT, + "images-to-pdf": TIER_LIGHT, + + # ─── Conversion (medium — server-side rendering) ──────────── + "pdf-to-word": TIER_MEDIUM, + "word-to-pdf": TIER_MEDIUM, + "pdf-to-excel": TIER_MEDIUM, + "excel-to-pdf": TIER_MEDIUM, + "pdf-to-pptx": TIER_MEDIUM, + "pptx-to-pdf": TIER_MEDIUM, + "html-to-pdf": TIER_MEDIUM, + "pdf-editor": TIER_MEDIUM, + + # ─── Image (light to medium) ──────────────────────────────── + "image-converter": TIER_LIGHT, + "image-resize": TIER_LIGHT, + "image-crop": TIER_LIGHT, + "image-rotate-flip": TIER_LIGHT, + "image-to-svg": TIER_MEDIUM, + + # ─── Image / PDF heavy (CPU/ML) ──────────────────────────── + "compress-pdf": TIER_HEAVY, + "compress-image": TIER_HEAVY, + "ocr": TIER_HEAVY, + "remove-background": TIER_HEAVY, + "remove-watermark-pdf": TIER_HEAVY, + + # ─── Utility ──────────────────────────────────────────────── + "qr-code": TIER_LIGHT, + "barcode-generator": TIER_LIGHT, + "video-to-gif": TIER_MEDIUM, + "word-counter": TIER_LIGHT, + "text-cleaner": TIER_LIGHT, + + # ─── AI-powered ───────────────────────────────────────────── + "chat-pdf": TIER_AI, + "summarize-pdf": TIER_AI, + "translate-pdf": TIER_AI, + "extract-tables": TIER_AI, + "pdf-flowchart": TIER_AI, + # ─── Route-specific aliases ───────────────────────────────────── + # Some routes record a tool name that differs from the manifest slug. + # Both names must map to the same cost. + "barcode": TIER_LIGHT, # manifest: barcode-generator + "image-convert": TIER_LIGHT, # manifest: image-converter + "ocr-image": TIER_HEAVY, # manifest: ocr + "ocr-pdf": TIER_HEAVY, # manifest: ocr + "pdf-flowchart-sample": TIER_AI, # manifest: pdf-flowchart + "pdf-edit": TIER_MEDIUM, # manifest: pdf-editor + "edit-metadata": TIER_LIGHT, # manifest: pdf-metadata + "remove-watermark": TIER_HEAVY, # manifest: remove-watermark-pdf + "remove-bg": TIER_HEAVY, # manifest: remove-background + "video-frames": TIER_MEDIUM, # route alias for video-to-gif + "edit-pdf-text": TIER_MEDIUM, # route alias for pdf-editor +} + +# Default cost for any tool not explicitly listed +DEFAULT_CREDIT_COST = TIER_LIGHT + + +def get_tool_credit_cost(tool: str) -> int: + """Return the credit cost for a given tool slug.""" + return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST) + + +def get_credits_for_plan(plan: str) -> int: + """Return the total credits per window for a plan.""" + return PRO_CREDITS_PER_WINDOW if plan == "pro" else FREE_CREDITS_PER_WINDOW + + +def get_all_tool_costs() -> dict[str, int]: + """Return the full cost registry — used by the config API endpoint.""" + return dict(TOOL_CREDIT_COSTS) diff --git a/backend/app/services/credit_service.py b/backend/app/services/credit_service.py new file mode 100644 index 0000000..aa2760f --- /dev/null +++ b/backend/app/services/credit_service.py @@ -0,0 +1,268 @@ +"""Credit window management — rolling 30-day balance for registered users. + +Handles lazy window creation on first use, automatic reset after expiry, +balance queries, and atomic credit deduction. +""" + +import logging +import os +from datetime import datetime, timedelta, timezone + +from app.services.credit_config import ( + CREDIT_WINDOW_DAYS, + get_credits_for_plan, + get_tool_credit_cost, +) +from app.utils.database import ( + db_connection, + execute_query, + is_postgres, + row_to_dict, +) + +logger = logging.getLogger(__name__) + +# ── Redis caching (optional) ─────────────────────────────────── +_BALANCE_CACHE_TTL = int(os.getenv("CREDIT_BALANCE_CACHE_TTL", "300")) # 5 min + + +def _get_redis(): + try: + import redis + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + return redis.Redis.from_url(redis_url, decode_responses=True) + except Exception: + return None + + +def _balance_cache_key(user_id: int) -> str: + return f"credit_balance:{user_id}" + + +def _invalidate_balance_cache(user_id: int) -> None: + r = _get_redis() + if r: + try: + r.delete(_balance_cache_key(user_id)) + except Exception: + pass + + +def _cache_balance(user_id: int, balance: int) -> None: + r = _get_redis() + if r: + try: + r.setex(_balance_cache_key(user_id), _BALANCE_CACHE_TTL, str(balance)) + except Exception: + pass + + +def _get_cached_balance(user_id: int) -> int | None: + r = _get_redis() + if r is None: + return None + try: + val = r.get(_balance_cache_key(user_id)) + return int(str(val)) if val is not None else None + except Exception: + return None + + +# ── Window helpers ────────────────────────────────────────────── + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _utc_now_dt() -> datetime: + return datetime.now(timezone.utc) + + +def _make_window_end(start_iso: str) -> str: + start = datetime.fromisoformat(start_iso) + end = start + timedelta(days=CREDIT_WINDOW_DAYS) + return end.isoformat() + + +def _is_window_expired(window_end_at: str) -> bool: + end = datetime.fromisoformat(window_end_at) + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + return _utc_now_dt() >= end + + +def _get_window(conn, user_id: int) -> dict | None: + sql = ( + "SELECT * FROM user_credit_windows WHERE user_id = %s" + if is_postgres() + else "SELECT * FROM user_credit_windows WHERE user_id = ?" + ) + cursor = execute_query(conn, sql, (user_id,)) + row = cursor.fetchone() + return row_to_dict(row) + + +def _create_window(conn, user_id: int, plan: str) -> dict: + now = _utc_now() + credits = get_credits_for_plan(plan) + end = _make_window_end(now) + + sql = ( + """ + INSERT INTO user_credit_windows + (user_id, window_start_at, window_end_at, credits_allocated, credits_used, plan, updated_at) + VALUES (%s, %s, %s, %s, 0, %s, %s) + ON CONFLICT (user_id) DO UPDATE SET + window_start_at = EXCLUDED.window_start_at, + window_end_at = EXCLUDED.window_end_at, + credits_allocated = EXCLUDED.credits_allocated, + credits_used = 0, + plan = EXCLUDED.plan, + updated_at = EXCLUDED.updated_at + """ + if is_postgres() + else """ + INSERT OR REPLACE INTO user_credit_windows + (user_id, window_start_at, window_end_at, credits_allocated, credits_used, plan, updated_at) + VALUES (?, ?, ?, ?, 0, ?, ?) + """ + ) + execute_query(conn, sql, (user_id, now, end, credits, plan, now)) + + return { + "user_id": user_id, + "window_start_at": now, + "window_end_at": end, + "credits_allocated": credits, + "credits_used": 0, + "plan": plan, + "updated_at": now, + } + + +def _reset_window(conn, user_id: int, plan: str) -> dict: + """Reset an expired window — starts a fresh 30-day period.""" + return _create_window(conn, user_id, plan) + + +# ── Public API ────────────────────────────────────────────────── + +def get_or_create_credit_window(user_id: int, plan: str) -> dict: + """Return the active credit window, creating or resetting as needed. + + This is the lazy initialization entrypoint: + - First call after registration creates the window. + - First call after window expiry resets it with a fresh allocation. + - Plan upgrades (free→pro) are reflected on the next reset. + """ + with db_connection() as conn: + window = _get_window(conn, user_id) + + if window is None: + window = _create_window(conn, user_id, plan) + logger.info("Created credit window for user %d (plan=%s)", user_id, plan) + return window + + if _is_window_expired(window["window_end_at"]): + window = _reset_window(conn, user_id, plan) + _invalidate_balance_cache(user_id) + logger.info("Reset expired credit window for user %d (plan=%s)", user_id, plan) + return window + + # If plan changed mid-window, update allocation (pro upgrade benefit) + expected_credits = get_credits_for_plan(plan) + if window["plan"] != plan and expected_credits > window["credits_allocated"]: + additional = expected_credits - window["credits_allocated"] + sql = ( + """ + UPDATE user_credit_windows + SET credits_allocated = credits_allocated + %s, plan = %s, updated_at = %s + WHERE user_id = %s + """ + if is_postgres() + else """ + UPDATE user_credit_windows + SET credits_allocated = credits_allocated + ?, plan = ?, updated_at = ? + WHERE user_id = ? + """ + ) + execute_query(conn, sql, (additional, plan, _utc_now(), user_id)) + window["credits_allocated"] += additional + window["plan"] = plan + _invalidate_balance_cache(user_id) + logger.info( + "Upgraded credit window for user %d: +%d credits (plan=%s)", + user_id, + additional, + plan, + ) + + return window + + +def get_rolling_balance(user_id: int, plan: str) -> int: + """Return remaining credits for the current window.""" + cached = _get_cached_balance(user_id) + if cached is not None: + return cached + + window = get_or_create_credit_window(user_id, plan) + balance = max(0, window["credits_allocated"] - window["credits_used"]) + _cache_balance(user_id, balance) + return balance + + +def deduct_credits(user_id: int, plan: str, tool: str) -> int: + """Deduct tool credits from the user's window. Returns the cost deducted. + + Raises ValueError if insufficient credits. + """ + cost = get_tool_credit_cost(tool) + + with db_connection() as conn: + # Ensure window is current + window = _get_window(conn, user_id) + if window is None or _is_window_expired(window.get("window_end_at", "")): + # get_or_create handles reset + pass + window = get_or_create_credit_window(user_id, plan) + + balance = window["credits_allocated"] - window["credits_used"] + if balance < cost: + raise ValueError( + f"Insufficient credits: {balance} remaining, {cost} required for {tool}." + ) + + sql = ( + """ + UPDATE user_credit_windows + SET credits_used = credits_used + %s, updated_at = %s + WHERE user_id = %s + """ + if is_postgres() + else """ + UPDATE user_credit_windows + SET credits_used = credits_used + ?, updated_at = ? + WHERE user_id = ? + """ + ) + execute_query(conn, sql, (cost, _utc_now(), user_id)) + + _invalidate_balance_cache(user_id) + return cost + + +def get_credit_summary(user_id: int, plan: str) -> dict: + """Return a full credit summary for the account page.""" + window = get_or_create_credit_window(user_id, plan) + balance = max(0, window["credits_allocated"] - window["credits_used"]) + return { + "credits_allocated": window["credits_allocated"], + "credits_used": window["credits_used"], + "credits_remaining": balance, + "window_start_at": window["window_start_at"], + "window_end_at": window["window_end_at"], + "plan": window["plan"], + "window_days": CREDIT_WINDOW_DAYS, + } diff --git a/backend/app/services/guest_budget_service.py b/backend/app/services/guest_budget_service.py new file mode 100644 index 0000000..f7ff49e --- /dev/null +++ b/backend/app/services/guest_budget_service.py @@ -0,0 +1,86 @@ +"""Guest demo budget enforcement. + +Anonymous visitors get a small usage budget tracked by IP address +via Redis (with Flask session fallback). The budget prevents abuse +of expensive tools before the download-gate forces registration. +""" + +import os +from flask import request, session + +from app.services.credit_config import GUEST_DEMO_BUDGET, GUEST_DEMO_TTL_HOURS + +_TTL_SECONDS = GUEST_DEMO_TTL_HOURS * 3600 + + +# ── Redis helpers ────────────────────────────────────────────── +def _get_redis(): + try: + import redis + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + return redis.Redis.from_url(redis_url, decode_responses=True) + except Exception: + return None + + +def _guest_redis_key(ip: str) -> str: + return f"guest_demo:{ip}" + + +def _get_client_ip() -> str: + """Return the best-effort client IP for rate tracking.""" + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.remote_addr or "unknown" + + +# ── Public API ───────────────────────────────────────────────── + +def get_guest_remaining() -> int: + """Return how many demo operations the current guest has left.""" + ip = _get_client_ip() + r = _get_redis() + + if r is not None: + try: + used = r.get(_guest_redis_key(ip)) + if used is None: + return GUEST_DEMO_BUDGET + return max(0, GUEST_DEMO_BUDGET - int(str(used))) + except Exception: + pass + + # Fallback: Flask session + used = session.get("guest_demo_used", 0) + return max(0, GUEST_DEMO_BUDGET - used) + + +def record_guest_usage() -> None: + """Increment the guest demo counter for the current visitor.""" + ip = _get_client_ip() + r = _get_redis() + + if r is not None: + try: + key = _guest_redis_key(ip) + pipe = r.pipeline() + pipe.incr(key) + pipe.expire(key, _TTL_SECONDS) + pipe.execute() + return + except Exception: + pass + + # Fallback: Flask session + session["guest_demo_used"] = session.get("guest_demo_used", 0) + 1 + + +def assert_guest_budget_available() -> None: + """Raise ValueError if the guest has exhausted their demo budget.""" + remaining = get_guest_remaining() + if remaining <= 0: + raise ValueError( + "You have used all your free demo tries. " + "Create a free account to continue." + ) diff --git a/backend/app/services/policy_service.py b/backend/app/services/policy_service.py index 6de8406..b9ff9cd 100644 --- a/backend/app/services/policy_service.py +++ b/backend/app/services/policy_service.py @@ -12,6 +12,23 @@ from app.services.account_service import ( normalize_plan, record_usage_event, ) +from app.services.credit_config import ( + get_tool_credit_cost, + get_credits_for_plan, + get_all_tool_costs, + GUEST_DEMO_BUDGET, + GUEST_DEMO_TTL_HOURS, + PRO_API_CREDITS_PER_WINDOW, +) +from app.services.credit_service import ( + deduct_credits, + get_credit_summary, + get_rolling_balance, +) +from app.services.guest_budget_service import ( + assert_guest_budget_available, + record_guest_usage, +) from app.utils.auth import get_current_user_id, logout_user_session from app.utils.auth import has_session_task_access, remember_task_access from app.utils.file_validator import validate_file @@ -19,10 +36,6 @@ from app.utils.file_validator import validate_file FREE_PLAN = "free" PRO_PLAN = "pro" -FREE_WEB_MONTHLY_LIMIT = 50 -PRO_WEB_MONTHLY_LIMIT = 500 -PRO_API_MONTHLY_LIMIT = 1000 - FREE_HISTORY_LIMIT = 25 PRO_HISTORY_LIMIT = 250 @@ -56,15 +69,15 @@ def get_history_limit(plan: str) -> int: def get_web_quota_limit(plan: str, actor_type: str) -> int | None: - """Return the monthly accepted-task cap for one web actor.""" + """Return the credit allocation for one web actor's window.""" if actor_type == "anonymous": return None - return PRO_WEB_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else FREE_WEB_MONTHLY_LIMIT + return get_credits_for_plan(normalize_plan(plan)) def get_api_quota_limit(plan: str) -> int | None: - """Return the monthly accepted-task cap for one API actor.""" - return PRO_API_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else None + """Return the credit allocation for one API actor's window.""" + return PRO_API_CREDITS_PER_WINDOW if normalize_plan(plan) == PRO_PLAN else None def ads_enabled(plan: str, actor_type: str) -> bool: @@ -97,27 +110,19 @@ def get_effective_file_size_limits_mb(plan: str) -> dict[str, int]: def get_usage_summary_for_user(user_id: int, plan: str) -> dict: """Return usage/quota summary for one authenticated user.""" normalized_plan = normalize_plan(plan) - current_period = get_current_period_month() - web_used = count_usage_events( - user_id, "web", event_type="accepted", period_month=current_period - ) - api_used = count_usage_events( - user_id, "api", event_type="accepted", period_month=current_period - ) + credit_info = get_credit_summary(user_id, normalized_plan) return { "plan": normalized_plan, - "period_month": current_period, "ads_enabled": ads_enabled(normalized_plan, "session"), "history_limit": get_history_limit(normalized_plan), "file_limits_mb": get_effective_file_size_limits_mb(normalized_plan), + "credits": credit_info, + "tool_costs": get_all_tool_costs(), + # Legacy fields for backward compatibility "web_quota": { - "used": web_used, - "limit": get_web_quota_limit(normalized_plan, "session"), - }, - "api_quota": { - "used": api_used, - "limit": get_api_quota_limit(normalized_plan), + "used": credit_info["credits_used"], + "limit": credit_info["credits_allocated"], }, } @@ -173,21 +178,38 @@ def validate_actor_file(file_storage, allowed_types: list[str], actor: ActorCont ) -def assert_quota_available(actor: ActorContext): - """Ensure an actor still has accepted-task quota for the current month.""" +def assert_quota_available(actor: ActorContext, tool: str | None = None): + """Ensure an actor still has credits for the requested tool. + + For registered users: checks rolling credit window balance. + For anonymous users: checks guest demo budget. + """ if actor.user_id is None: + # Guest demo budget enforcement + try: + assert_guest_budget_available() + except ValueError: + raise PolicyError( + "You have used all your free demo tries. " + "Create a free account to continue.", + 429, + ) return if actor.source == "web": - limit = get_web_quota_limit(actor.plan, actor.actor_type) - if limit is None: - return - used = count_usage_events(actor.user_id, "web", event_type="accepted") - if used >= limit: + # Credit-based check + cost = get_tool_credit_cost(tool) if tool else 1 + balance = get_rolling_balance(actor.user_id, actor.plan) + if balance < cost: if normalize_plan(actor.plan) == PRO_PLAN: - raise PolicyError("Your monthly Pro web quota has been reached.", 429) + raise PolicyError( + f"Your Pro credit balance is exhausted ({balance} remaining, " + f"{cost} required). Credits reset at the end of your 30-day window.", + 429, + ) raise PolicyError( - "Your monthly free plan limit has been reached. Upgrade to Pro for higher limits.", + f"Your free credit balance is exhausted ({balance} remaining, " + f"{cost} required). Upgrade to Pro for more credits.", 429, ) return @@ -202,10 +224,29 @@ def assert_quota_available(actor: ActorContext): def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): - """Record one accepted usage event after task dispatch succeeds.""" + """Record one accepted usage event and deduct credits after task dispatch.""" if actor.source == "web": remember_task_access(celery_task_id) + cost = get_tool_credit_cost(tool) + + # Deduct credits from the rolling window (registered users only) + if actor.user_id is not None and actor.source == "web": + try: + deduct_credits(actor.user_id, actor.plan, tool) + except ValueError: + # Balance check should have caught this in assert_quota_available. + # Log but don't block — the usage event is the source of truth. + import logging + logging.getLogger(__name__).warning( + "Credit deduction failed for user %d tool %s (insufficient balance at record time)", + actor.user_id, + tool, + ) + elif actor.user_id is None and actor.source == "web": + # Record guest demo usage + record_guest_usage() + record_usage_event( user_id=actor.user_id, source=actor.source, @@ -213,6 +254,7 @@ def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): task_id=celery_task_id, event_type="accepted", api_key_id=actor.api_key_id, + cost_points=cost, ) diff --git a/backend/app/services/translation_guardrails.py b/backend/app/services/translation_guardrails.py new file mode 100644 index 0000000..a3e900a --- /dev/null +++ b/backend/app/services/translation_guardrails.py @@ -0,0 +1,151 @@ +"""Translation guardrails — admission control, caching, and cost protection. + +This module implements the guardrail model described in +docs/tool-portfolio/05-ai-cost-and-performance-plan.md. +""" + +import hashlib +import logging +import os +from typing import Optional + +from flask import current_app + +logger = logging.getLogger(__name__) + +# ── Page-count admission tiers ────────────────────────────────────── +# These limits define the maximum number of pages allowed per plan. +# Free/anonymous users get a lower cap; Pro users get a higher cap. +FREE_TRANSLATE_MAX_PAGES = int(os.getenv("FREE_TRANSLATE_MAX_PAGES", "10")) +PRO_TRANSLATE_MAX_PAGES = int(os.getenv("PRO_TRANSLATE_MAX_PAGES", "50")) + + +class TranslationAdmissionError(Exception): + """Raised when a translation job is rejected at admission.""" + + def __init__(self, message: str, status_code: int = 400): + super().__init__(message) + self.message = message + self.status_code = status_code + + +def get_page_limit(plan: str) -> int: + """Return the page cap for a given plan.""" + from app.services.account_service import normalize_plan + + if normalize_plan(plan) == "pro": + return PRO_TRANSLATE_MAX_PAGES + return FREE_TRANSLATE_MAX_PAGES + + +def count_pdf_pages(file_path: str) -> int: + """Return the number of pages in a PDF file.""" + try: + from PyPDF2 import PdfReader + + reader = PdfReader(file_path) + return len(reader.pages) + except Exception as e: + logger.warning("Failed to count PDF pages for admission: %s", e) + # If we can't count pages, allow the job through but log it + return 0 + + +def check_page_admission(file_path: str, plan: str) -> int: + """Verify a PDF is within the page limit for the given plan. + + Returns the page count on success. + Raises TranslationAdmissionError if the file exceeds the limit. + """ + page_count = count_pdf_pages(file_path) + if page_count == 0: + # Can't determine — allow through (OCR fallback scenario) + return page_count + + limit = get_page_limit(plan) + if page_count > limit: + raise TranslationAdmissionError( + f"This PDF has {page_count} pages. " + f"Your plan allows up to {limit} pages for translation. " + f"Please upgrade your plan or use a smaller file.", + status_code=413, + ) + return page_count + + +# ── Content-hash caching ──────────────────────────────────────────── +# Redis-based cache keyed by file-content hash + target language. +# Avoids re-translating identical documents. + +TRANSLATION_CACHE_TTL = int(os.getenv("TRANSLATION_CACHE_TTL", str(7 * 24 * 3600))) # 7 days + + +def _get_redis(): + """Get Redis connection from Flask app config.""" + try: + import redis + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + return redis.Redis.from_url(redis_url, decode_responses=True) + except Exception as e: + logger.debug("Redis not available for translation cache: %s", e) + return None + + +def _compute_content_hash(file_path: str) -> str: + """Compute SHA-256 hash of file contents.""" + sha = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha.update(chunk) + return sha.hexdigest() + + +def _cache_key(content_hash: str, target_language: str, source_language: str) -> str: + """Build a Redis key for the translation cache.""" + return f"translate_cache:{content_hash}:{source_language}:{target_language}" + + +def get_cached_translation( + file_path: str, target_language: str, source_language: str = "auto" +) -> Optional[dict]: + """Look up a cached translation result. Returns None on miss.""" + r = _get_redis() + if r is None: + return None + + try: + content_hash = _compute_content_hash(file_path) + key = _cache_key(content_hash, target_language, source_language) + import json + + cached = r.get(key) + if cached: + logger.info("Translation cache hit for %s", key) + return json.loads(cached) + except Exception as e: + logger.debug("Translation cache lookup failed: %s", e) + + return None + + +def store_cached_translation( + file_path: str, + target_language: str, + source_language: str, + result: dict, +) -> None: + """Store a successful translation result in Redis.""" + r = _get_redis() + if r is None: + return + + try: + import json + + content_hash = _compute_content_hash(file_path) + key = _cache_key(content_hash, target_language, source_language) + r.setex(key, TRANSLATION_CACHE_TTL, json.dumps(result, ensure_ascii=False)) + logger.info("Translation cached: %s (TTL=%ds)", key, TRANSLATION_CACHE_TTL) + except Exception as e: + logger.debug("Translation cache store failed: %s", e) diff --git a/backend/app/tasks/pdf_ai_tasks.py b/backend/app/tasks/pdf_ai_tasks.py index a0686dc..0703aba 100644 --- a/backend/app/tasks/pdf_ai_tasks.py +++ b/backend/app/tasks/pdf_ai_tasks.py @@ -15,6 +15,10 @@ from app.services.pdf_ai_service import ( PdfAiError, ) from app.services.task_tracking_service import finalize_task_tracking +from app.services.translation_guardrails import ( + get_cached_translation, + store_cached_translation, +) from app.utils.sanitizer import cleanup_task_files logger = logging.getLogger(__name__) @@ -214,9 +218,24 @@ def translate_pdf_task( meta={"step": "Translating document with provider fallback..."}, ) - data = translate_pdf( - input_path, target_language, source_language=source_language + # ── Cache lookup — skip AI call if identical translation exists ── + cached = get_cached_translation( + input_path, target_language, source_language or "auto" ) + if cached is not None: + data = cached + data["provider"] = f"{data.get('provider', 'unknown')} (cached)" + else: + data = translate_pdf( + input_path, target_language, source_language=source_language + ) + # Store successful result for future cache hits + store_cached_translation( + input_path, + target_language, + source_language or "auto", + data, + ) result = { "status": "completed", diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 408bbdb..49ae0da 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -46,7 +46,8 @@ class TestConfigEndpoint: usage = data["usage"] assert usage["plan"] == "free" assert "web_quota" in usage - assert "api_quota" in usage + assert "credits" in usage + assert usage["credits"]["credits_allocated"] == 50 def test_max_upload_mb_is_correct(self, client): """max_upload_mb should equal the largest single-type limit.""" diff --git a/backend/tests/test_download.py b/backend/tests/test_download.py index c2ce3b1..52d2164 100644 --- a/backend/tests/test_download.py +++ b/backend/tests/test_download.py @@ -1,12 +1,24 @@ """Tests for file download route.""" import os +from app.services.account_service import create_user from app.utils.auth import TASK_ACCESS_SESSION_KEY class TestDownload: - def test_download_nonexistent_file(self, client): - """Should return 404 for missing file.""" + def test_download_anonymous_returns_401(self, client): + """Anonymous users should be blocked by the download gate.""" + response = client.get('/api/download/some-task-id/output.pdf') + assert response.status_code == 401 + assert response.get_json()['error'] == 'signup_required' + + def test_download_nonexistent_file(self, client, app): + """Should return 404 for missing file when authenticated.""" + with app.app_context(): + user = create_user('download-test@example.com', 'pass12345') + with client.session_transaction() as session: + session['user_id'] = user['id'] + session[TASK_ACCESS_SESSION_KEY] = ['some-task-id'] response = client.get('/api/download/some-task-id/output.pdf') assert response.status_code == 404 @@ -22,10 +34,13 @@ class TestDownload: assert response.status_code in (400, 404) def test_download_valid_file(self, client, app): - """Should serve file if it exists.""" + """Should serve file if it exists and user is authenticated.""" task_id = 'test-download-id' filename = 'output.pdf' + with app.app_context(): + user = create_user('download-valid@example.com', 'pass12345') + # Create the file in the output directory output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) os.makedirs(output_dir, exist_ok=True) @@ -34,6 +49,7 @@ class TestDownload: f.write(b'%PDF-1.4 test content') with client.session_transaction() as session: + session['user_id'] = user['id'] session[TASK_ACCESS_SESSION_KEY] = [task_id] response = client.get(f'/api/download/{task_id}/{filename}') @@ -45,26 +61,37 @@ class TestDownload: task_id = 'test-name-id' filename = 'output.pdf' + with app.app_context(): + user = create_user('download-name@example.com', 'pass12345') + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) os.makedirs(output_dir, exist_ok=True) with open(os.path.join(output_dir, filename), 'wb') as f: f.write(b'%PDF-1.4') with client.session_transaction() as session: + session['user_id'] = user['id'] session[TASK_ACCESS_SESSION_KEY] = [task_id] response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf') assert response.status_code == 200 def test_download_requires_task_access(self, client, app): - """Should not serve an existing file without session or API ownership.""" + """Should not serve an existing file without task access, even if authenticated.""" task_id = 'protected-download-id' filename = 'output.pdf' + with app.app_context(): + user = create_user('download-noaccess@example.com', 'pass12345') + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) os.makedirs(output_dir, exist_ok=True) with open(os.path.join(output_dir, filename), 'wb') as f: f.write(b'%PDF-1.4 protected') + with client.session_transaction() as session: + session['user_id'] = user['id'] + # No TASK_ACCESS_SESSION_KEY set — user can't access this task + response = client.get(f'/api/download/{task_id}/{filename}') assert response.status_code == 404 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 45ce759..b7efb59 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: celery -A celery_worker.celery worker --loglevel=warning --concurrency=4 - -Q default,convert,compress,image,video,pdf_tools,flowchart + -Q default,convert,compress,image,video,pdf_tools,flowchart,ai_heavy env_file: - .env environment: diff --git a/docker-compose.yml b/docker-compose.yml index 7ddcff0..c734251 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,7 @@ services: celery -A celery_worker.celery worker --loglevel=info --concurrency=2 - -Q default,convert,compress,image,video,pdf_tools,flowchart + -Q default,convert,compress,image,video,pdf_tools,flowchart,ai_heavy env_file: - .env environment: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c0fe32..a57bf61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ToolLandingPage from '@/components/seo/ToolLandingPage'; import { useDirection } from '@/hooks/useDirection'; import { initAnalytics, trackPageView } from '@/services/analytics'; import { useAuthStore } from '@/stores/authStore'; +import { TOOL_MANIFEST } from '@/config/toolManifest'; let clarityInitialized = false; @@ -32,53 +33,10 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage')); const CookieConsent = lazy(() => import('@/components/layout/CookieConsent')); const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant')); -// Tool Pages -const PdfToWord = lazy(() => import('@/components/tools/PdfToWord')); -const WordToPdf = lazy(() => import('@/components/tools/WordToPdf')); -const PdfCompressor = lazy(() => import('@/components/tools/PdfCompressor')); -const ImageConverter = lazy(() => import('@/components/tools/ImageConverter')); -const VideoToGif = lazy(() => import('@/components/tools/VideoToGif')); -const WordCounter = lazy(() => import('@/components/tools/WordCounter')); -const TextCleaner = lazy(() => import('@/components/tools/TextCleaner')); -const MergePdf = lazy(() => import('@/components/tools/MergePdf')); -const SplitPdf = lazy(() => import('@/components/tools/SplitPdf')); -const RotatePdf = lazy(() => import('@/components/tools/RotatePdf')); -const PdfToImages = lazy(() => import('@/components/tools/PdfToImages')); -const ImagesToPdf = lazy(() => import('@/components/tools/ImagesToPdf')); -const WatermarkPdf = lazy(() => import('@/components/tools/WatermarkPdf')); -const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf')); -const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf')); -const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers')); -const PdfEditor = lazy(() => import('@/components/tools/PdfEditor')); -const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart')); -const ImageResize = lazy(() => import('@/components/tools/ImageResize')); -const OcrTool = lazy(() => import('@/components/tools/OcrTool')); -const RemoveBackground = lazy(() => import('@/components/tools/RemoveBackground')); -const CompressImage = lazy(() => import('@/components/tools/CompressImage')); -const PdfToExcel = lazy(() => import('@/components/tools/PdfToExcel')); -const RemoveWatermark = lazy(() => import('@/components/tools/RemoveWatermark')); -const ReorderPdf = lazy(() => import('@/components/tools/ReorderPdf')); -const ExtractPages = lazy(() => import('@/components/tools/ExtractPages')); -const QrCodeGenerator = lazy(() => import('@/components/tools/QrCodeGenerator')); -const HtmlToPdf = lazy(() => import('@/components/tools/HtmlToPdf')); -const ChatPdf = lazy(() => import('@/components/tools/ChatPdf')); -const SummarizePdf = lazy(() => import('@/components/tools/SummarizePdf')); -const TranslatePdf = lazy(() => import('@/components/tools/TranslatePdf')); -const TableExtractor = lazy(() => import('@/components/tools/TableExtractor')); - -// Phase 2 lazy imports -const PdfToPptx = lazy(() => import('@/components/tools/PdfToPptx')); -const ExcelToPdf = lazy(() => import('@/components/tools/ExcelToPdf')); -const PptxToPdf = lazy(() => import('@/components/tools/PptxToPdf')); -const SignPdf = lazy(() => import('@/components/tools/SignPdf')); -const CropPdf = lazy(() => import('@/components/tools/CropPdf')); -const FlattenPdf = lazy(() => import('@/components/tools/FlattenPdf')); -const RepairPdf = lazy(() => import('@/components/tools/RepairPdf')); -const PdfMetadata = lazy(() => import('@/components/tools/PdfMetadata')); -const ImageCrop = lazy(() => import('@/components/tools/ImageCrop')); -const ImageToSvg = lazy(() => import('@/components/tools/ImageToSvg')); -const ImageRotateFlip = lazy(() => import('@/components/tools/ImageRotateFlip')); -const BarcodeGenerator = lazy(() => import('@/components/tools/BarcodeGenerator')); +// Tool components — derived from manifest using React.lazy +const ToolComponents = Object.fromEntries( + TOOL_MANIFEST.map((tool) => [tool.slug, lazy(tool.component)]) +) as Record>; function LoadingFallback() { return ( @@ -165,71 +123,17 @@ export default function App() { } /> } /> - {/* PDF Tools */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Image Tools */} - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Convert Tools */} - } /> - } /> - - {/* PDF Extra Tools */} - } /> - } /> - } /> - - {/* AI Tools */} - } /> - } /> - } /> - } /> - - {/* Other Tools */} - } /> - - {/* Video Tools */} - } /> - - {/* Text Tools */} - } /> - } /> - - {/* Phase 2 – PDF Conversion */} - } /> - } /> - } /> - } /> - - {/* Phase 2 – PDF Extra */} - } /> - } /> - } /> - } /> - - {/* Phase 2 – Image & Utility */} - } /> - } /> - } /> + {/* Tool Routes — driven by the unified manifest */} + {TOOL_MANIFEST.map((tool) => { + const Component = ToolComponents[tool.slug]; + return ( + } + /> + ); + })} {/* 404 */} } /> diff --git a/frontend/src/components/shared/DownloadButton.tsx b/frontend/src/components/shared/DownloadButton.tsx index 3c4f0b7..f5eacd8 100644 --- a/frontend/src/components/shared/DownloadButton.tsx +++ b/frontend/src/components/shared/DownloadButton.tsx @@ -1,12 +1,15 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; -import { Download, RotateCcw, Clock } from 'lucide-react'; +import { Download, RotateCcw, Clock, Lock } from 'lucide-react'; import type { TaskResult } from '@/services/api'; import { formatFileSize } from '@/utils/textTools'; import { trackEvent } from '@/services/analytics'; import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt'; import SharePanel from '@/components/shared/SharePanel'; import SuggestedTools from '@/components/seo/SuggestedTools'; +import SignUpToDownloadModal from '@/components/shared/SignUpToDownloadModal'; +import { useAuthStore } from '@/stores/authStore'; interface DownloadButtonProps { /** Task result containing download URL */ @@ -18,10 +21,21 @@ interface DownloadButtonProps { export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) { const { t } = useTranslation(); const location = useLocation(); + const user = useAuthStore((s) => s.user); + const [showGateModal, setShowGateModal] = useState(false); const currentToolSlug = location.pathname.startsWith('/tools/') ? location.pathname.replace('/tools/', '') : null; + // Extract the download task ID from the download URL path + // URL format: /api/download// + const downloadTaskId = (() => { + if (!result.download_url) return undefined; + const parts = result.download_url.split('/'); + const idx = parts.indexOf('download'); + return idx >= 0 && parts.length > idx + 1 ? parts[idx + 1] : undefined; + })(); + const handleDownloadClick = () => { trackEvent('download_clicked', { filename: result.filename || 'unknown' }); dispatchCurrentToolRatingPrompt(); @@ -72,17 +86,35 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr )} {/* Download button */} - - - {t('common.download')} — {result.filename} - + {user ? ( + + + {t('common.download')} — {result.filename} + + ) : ( + + )} + + {showGateModal && ( + setShowGateModal(false)} + taskId={downloadTaskId} + toolSlug={currentToolSlug ?? undefined} + /> + )}
void; + /** Download task ID extracted from the download URL. */ + taskId?: string; + /** Tool slug for credit accounting. */ + toolSlug?: string; +} + +export default function SignUpToDownloadModal({ + onClose, + taskId, + toolSlug, +}: SignUpToDownloadModalProps) { + const { t } = useTranslation(); + const { login, register } = useAuthStore(); + + const [mode, setMode] = useState<'register' | 'login'>('register'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (mode === 'register' && password !== confirmPassword) { + setError(t('account.passwordMismatch')); + return; + } + + setLoading(true); + try { + if (mode === 'login') { + await login(email, password); + } else { + await register(email, password); + } + + // Claim the anonymous task into the new account's history + if (taskId && toolSlug) { + try { + await claimTask(taskId, toolSlug); + } catch { + // Non-blocking — file is still downloadable via session + } + } + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('account.loadFailed')); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Close button */} + + + {/* Header */} +
+
+ +
+

+ {t('downloadGate.title')} +

+

+ {t('downloadGate.subtitle')} +

+
+ + {/* Benefits — compact */} +
    + {[ + t('downloadGate.benefit1'), + t('downloadGate.benefit2'), + t('downloadGate.benefit3'), + ].map((b, i) => ( +
  • + + {b} +
  • + ))} +
+ + {/* Inline auth form */} +
+ setEmail(e.target.value)} + placeholder={t('account.emailPlaceholder')} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30" + /> + setPassword(e.target.value)} + placeholder={t('account.passwordPlaceholder')} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30" + /> + {mode === 'register' && ( + setConfirmPassword(e.target.value)} + placeholder={t('account.confirmPasswordPlaceholder')} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:focus:ring-primary-900/30" + /> + )} + + {error && ( +

{error}

+ )} + + +
+ + {/* Toggle login / register */} + +
+
+ ); +} diff --git a/frontend/src/components/shared/ToolSelectorModal.tsx b/frontend/src/components/shared/ToolSelectorModal.tsx index 3e4049a..2876c40 100644 --- a/frontend/src/components/shared/ToolSelectorModal.tsx +++ b/frontend/src/components/shared/ToolSelectorModal.tsx @@ -86,7 +86,7 @@ export default function ToolSelectorModal({ aria-modal="true" aria-labelledby="tool-selector-title" > -
+
{/* Header */}
@@ -123,26 +123,28 @@ export default function ToolSelectorModal({
{/* Tools Grid */} -
- {tools.map((tool) => { - const Icon = tool.icon; - return ( -
- - {t(`tools.${tool.key}.shortDesc`)} - - - ); - })} +
+ +
+ + {t(`tools.${tool.key}.shortDesc`)} + + + ); + })} +
diff --git a/frontend/src/config/routes.test.ts b/frontend/src/config/routes.test.ts index a432806..56eb971 100644 --- a/frontend/src/config/routes.test.ts +++ b/frontend/src/config/routes.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { ALL_ROUTES } from '@/config/routes'; +import { ALL_ROUTES, TOOL_ROUTES } from '@/config/routes'; import { getAllSeoLandingPaths } from '@/config/seoPages'; const __filename = fileURLToPath(import.meta.url); @@ -12,7 +12,8 @@ const __dirname = dirname(__filename); * SAFETY TEST — Route Integrity * * Ensures that every route in the canonical registry (routes.ts) - * has a matching in App.tsx. + * has a matching in App.tsx — either as a static + * path="..." attribute or via the TOOL_MANIFEST dynamic loop. * * If this test fails it means either: * 1. A route was removed from App.tsx (NEVER do this) @@ -25,7 +26,7 @@ describe('Route safety', () => { ); const seoLandingPaths = new Set(getAllSeoLandingPaths()); - // Extract all path="..." values from elements + // Extract all static path="..." values from elements const routePathRegex = /path="([^"]+)"/g; const appPaths = new Set(); let match: RegExpExecArray | null; @@ -33,6 +34,11 @@ describe('Route safety', () => { if (match[1] !== '*') appPaths.add(match[1]); } + // Detect manifest-driven routing: if App.tsx renders tool routes via + // TOOL_MANIFEST.map, every TOOL_ROUTES entry is covered dynamically. + const hasManifestLoop = appSource.includes('TOOL_MANIFEST.map'); + const toolRouteSet = new Set(TOOL_ROUTES as readonly string[]); + it('App.tsx contains routes for every entry in the route registry', () => { const hasDynamicSeoRoute = appPaths.has('/:slug'); const missing = ALL_ROUTES.filter((route) => { @@ -40,6 +46,11 @@ describe('Route safety', () => { return false; } + // Tool routes covered by the manifest loop + if (hasManifestLoop && toolRouteSet.has(route)) { + return false; + } + if (hasDynamicSeoRoute && seoLandingPaths.has(route)) { return false; } diff --git a/frontend/src/config/routes.ts b/frontend/src/config/routes.ts index b774deb..6600452 100644 --- a/frontend/src/config/routes.ts +++ b/frontend/src/config/routes.ts @@ -4,9 +4,12 @@ * SAFETY RULE: Never remove a route from this list. * New routes may only be appended. The route safety test * (routes.test.ts) will fail if any existing route is deleted. + * + * Tool routes are now derived from the unified manifest (toolManifest.ts). */ import { getAllSeoLandingPaths } from '@/config/seoPages'; +import { getManifestRoutePaths } from '@/config/toolManifest'; const STATIC_PAGE_ROUTES = [ '/', @@ -35,68 +38,8 @@ export const PAGE_ROUTES = [ '/ar/:slug', ] as const; -// ─── Tool routes ───────────────────────────────────────────────── -export const TOOL_ROUTES = [ - // PDF Tools - '/tools/pdf-to-word', - '/tools/word-to-pdf', - '/tools/compress-pdf', - '/tools/merge-pdf', - '/tools/split-pdf', - '/tools/rotate-pdf', - '/tools/pdf-to-images', - '/tools/images-to-pdf', - '/tools/watermark-pdf', - '/tools/protect-pdf', - '/tools/unlock-pdf', - '/tools/page-numbers', - '/tools/pdf-editor', - '/tools/pdf-flowchart', - '/tools/pdf-to-excel', - '/tools/remove-watermark-pdf', - '/tools/reorder-pdf', - '/tools/extract-pages', - - // Image Tools - '/tools/image-converter', - '/tools/image-resize', - '/tools/compress-image', - '/tools/ocr', - '/tools/remove-background', - '/tools/image-to-svg', - - // Convert Tools - '/tools/html-to-pdf', - - // AI Tools - '/tools/chat-pdf', - '/tools/summarize-pdf', - '/tools/translate-pdf', - '/tools/extract-tables', - - // Other Tools - '/tools/qr-code', - '/tools/video-to-gif', - '/tools/word-counter', - '/tools/text-cleaner', - - // Phase 2 – PDF Conversion - '/tools/pdf-to-pptx', - '/tools/excel-to-pdf', - '/tools/pptx-to-pdf', - '/tools/sign-pdf', - - // Phase 2 – PDF Extra Tools - '/tools/crop-pdf', - '/tools/flatten-pdf', - '/tools/repair-pdf', - '/tools/pdf-metadata', - - // Phase 2 – Image & Utility - '/tools/image-crop', - '/tools/image-rotate-flip', - '/tools/barcode-generator', -] as const; +// ─── Tool routes (derived from manifest) ───────────────────────── +export const TOOL_ROUTES = getManifestRoutePaths() as unknown as readonly string[]; // ─── All routes combined ───────────────────────────────────────── export const ALL_ROUTES = [...PAGE_ROUTES, ...TOOL_ROUTES] as const; diff --git a/frontend/src/config/toolManifest.test.ts b/frontend/src/config/toolManifest.test.ts new file mode 100644 index 0000000..10a0612 --- /dev/null +++ b/frontend/src/config/toolManifest.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { TOOL_MANIFEST, getManifestSlugs } from '@/config/toolManifest'; +import { getAllToolSlugs, getToolSEO } from '@/config/seoData'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * DRIFT-DETECTION TESTS + * + * Ensures toolManifest.ts stays in sync with seoData.ts and HomePage.tsx. + * If any test fails it means someone added a tool in one place but forgot + * the other — fix by updating both files. + */ +describe('Tool Manifest ↔ SEO Data sync', () => { + const manifestSlugs = new Set(getManifestSlugs()); + const seoSlugs = new Set(getAllToolSlugs()); + + it('every manifest tool has an seoData entry', () => { + const missing = [...manifestSlugs].filter((s) => !seoSlugs.has(s)); + expect(missing, `Manifest tools missing seoData: ${missing.join(', ')}`).toEqual( + [] + ); + }); + + it('every seoData tool has a manifest entry', () => { + const missing = [...seoSlugs].filter((s) => !manifestSlugs.has(s)); + expect(missing, `seoData tools missing manifest: ${missing.join(', ')}`).toEqual( + [] + ); + }); + + it('no duplicate slugs in the manifest', () => { + const seen = new Set(); + const dupes: string[] = []; + for (const tool of TOOL_MANIFEST) { + if (seen.has(tool.slug)) dupes.push(tool.slug); + seen.add(tool.slug); + } + expect(dupes, `Duplicate manifest slugs: ${dupes.join(', ')}`).toEqual([]); + }); + + it('no duplicate slugs in seoData', () => { + const all = getAllToolSlugs(); + expect(new Set(all).size).toBe(all.length); + }); + + it('each seoData entry has required fields populated', () => { + for (const slug of seoSlugs) { + const seo = getToolSEO(slug); + expect(seo, `seoData missing entry for slug: ${slug}`).toBeDefined(); + expect(seo!.titleSuffix?.length).toBeGreaterThan(0); + expect(seo!.metaDescription?.length).toBeGreaterThan(0); + } + }); +}); + +describe('Tool Manifest ↔ HomePage ICON_MAP sync', () => { + const homePageSource = readFileSync( + resolve(__dirname, '../pages/HomePage.tsx'), + 'utf-8' + ); + + // Extract icon names from the ICON_MAP object literal + // Match from "= {" to "};" to skip the type annotation that also contains braces + const iconMapMatch = homePageSource.match(/ICON_MAP[^=]+=\s*\{([\s\S]+?)\};/); + const iconMapKeys = new Set( + iconMapMatch + ? iconMapMatch[1] + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean) + : [] + ); + + it('every homepage-visible manifest tool has its icon in ICON_MAP', () => { + const missing: string[] = []; + for (const tool of TOOL_MANIFEST) { + if (tool.homepage && !iconMapKeys.has(tool.iconName)) { + missing.push(`${tool.slug} (icon: ${tool.iconName})`); + } + } + expect( + missing, + `Homepage tools with missing ICON_MAP entries: ${missing.join(', ')}` + ).toEqual([]); + }); +}); + +describe('Tool Manifest internal consistency', () => { + it('all manifest entries have non-empty slugs and i18nKeys', () => { + for (const tool of TOOL_MANIFEST) { + expect(tool.slug.length).toBeGreaterThan(0); + expect(tool.i18nKey.length).toBeGreaterThan(0); + } + }); + + it('all manifest slugs follow kebab-case pattern', () => { + const kebab = /^[a-z0-9]+(-[a-z0-9]+)*$/; + for (const tool of TOOL_MANIFEST) { + expect( + kebab.test(tool.slug), + `Slug "${tool.slug}" is not kebab-case` + ).toBe(true); + } + }); + + it('manifest has at least 40 tools', () => { + expect(TOOL_MANIFEST.length).toBeGreaterThanOrEqual(40); + }); +}); diff --git a/frontend/src/config/toolManifest.ts b/frontend/src/config/toolManifest.ts new file mode 100644 index 0000000..0aa760c --- /dev/null +++ b/frontend/src/config/toolManifest.ts @@ -0,0 +1,601 @@ +/** + * Unified Tool Manifest — the single source of truth for every tool. + * + * Every consumer (App.tsx routes, HomePage grid, seoData, routes.ts, sitemap) + * should derive its list from this manifest instead of maintaining a separate + * hard-coded array. This eliminates drift between route definitions, SEO + * metadata, and homepage visibility. + * + * SAFETY RULE: Never remove an entry. New tools may only be appended. + */ + +// ── Types ────────────────────────────────────────────────────────── +export type ToolCategory = 'pdf-core' | 'pdf-extended' | 'image' | 'conversion' | 'ai' | 'utility'; + +export interface ToolEntry { + /** URL slug under /tools/ — also used as the unique key */ + slug: string; + /** i18n key used in `tools..title` / `tools..shortDesc` */ + i18nKey: string; + /** Lazy-import factory — returns the React component */ + component: () => Promise<{ default: React.ComponentType }>; + /** Portfolio category */ + category: ToolCategory; + /** Visible on homepage grid */ + homepage: boolean; + /** Homepage section: 'pdf' tools section or 'other' tools section */ + homepageSection?: 'pdf' | 'other'; + /** Lucide icon name to render (used by HomePage) */ + iconName: string; + /** Tailwind text-color class for the icon */ + iconColor: string; + /** Tailwind bg-color class for the card */ + bgColor: string; + /** Demand tier from portfolio analysis */ + demandTier: 'A' | 'B' | 'C'; +} + +// ── Manifest ─────────────────────────────────────────────────────── +export const TOOL_MANIFEST: readonly ToolEntry[] = [ + // ─── PDF Core ────────────────────────────────────────────────── + { + slug: 'pdf-editor', + i18nKey: 'pdfEditor', + component: () => import('@/components/tools/PdfEditor'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'PenLine', + iconColor: 'text-rose-600', + bgColor: 'bg-rose-50', + demandTier: 'A', + }, + { + slug: 'pdf-to-word', + i18nKey: 'pdfToWord', + component: () => import('@/components/tools/PdfToWord'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileText', + iconColor: 'text-red-600', + bgColor: 'bg-red-50', + demandTier: 'A', + }, + { + slug: 'word-to-pdf', + i18nKey: 'wordToPdf', + component: () => import('@/components/tools/WordToPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileOutput', + iconColor: 'text-blue-600', + bgColor: 'bg-blue-50', + demandTier: 'A', + }, + { + slug: 'compress-pdf', + i18nKey: 'compressPdf', + component: () => import('@/components/tools/PdfCompressor'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Minimize2', + iconColor: 'text-orange-600', + bgColor: 'bg-orange-50', + demandTier: 'A', + }, + { + slug: 'merge-pdf', + i18nKey: 'mergePdf', + component: () => import('@/components/tools/MergePdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Layers', + iconColor: 'text-violet-600', + bgColor: 'bg-violet-50', + demandTier: 'A', + }, + { + slug: 'split-pdf', + i18nKey: 'splitPdf', + component: () => import('@/components/tools/SplitPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Scissors', + iconColor: 'text-pink-600', + bgColor: 'bg-pink-50', + demandTier: 'A', + }, + { + slug: 'rotate-pdf', + i18nKey: 'rotatePdf', + component: () => import('@/components/tools/RotatePdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'RotateCw', + iconColor: 'text-teal-600', + bgColor: 'bg-teal-50', + demandTier: 'A', + }, + { + slug: 'pdf-to-images', + i18nKey: 'pdfToImages', + component: () => import('@/components/tools/PdfToImages'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Image', + iconColor: 'text-amber-600', + bgColor: 'bg-amber-50', + demandTier: 'A', + }, + { + slug: 'images-to-pdf', + i18nKey: 'imagesToPdf', + component: () => import('@/components/tools/ImagesToPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileImage', + iconColor: 'text-lime-600', + bgColor: 'bg-lime-50', + demandTier: 'A', + }, + { + slug: 'watermark-pdf', + i18nKey: 'watermarkPdf', + component: () => import('@/components/tools/WatermarkPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Droplets', + iconColor: 'text-cyan-600', + bgColor: 'bg-cyan-50', + demandTier: 'B', + }, + { + slug: 'protect-pdf', + i18nKey: 'protectPdf', + component: () => import('@/components/tools/ProtectPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Lock', + iconColor: 'text-red-600', + bgColor: 'bg-red-50', + demandTier: 'A', + }, + { + slug: 'unlock-pdf', + i18nKey: 'unlockPdf', + component: () => import('@/components/tools/UnlockPdf'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'Unlock', + iconColor: 'text-green-600', + bgColor: 'bg-green-50', + demandTier: 'A', + }, + { + slug: 'page-numbers', + i18nKey: 'pageNumbers', + component: () => import('@/components/tools/AddPageNumbers'), + category: 'pdf-core', + homepage: true, + homepageSection: 'pdf', + iconName: 'ListOrdered', + iconColor: 'text-sky-600', + bgColor: 'bg-sky-50', + demandTier: 'B', + }, + + // ─── PDF Extended ────────────────────────────────────────────── + { + slug: 'pdf-flowchart', + i18nKey: 'pdfFlowchart', + component: () => import('@/components/tools/PdfFlowchart'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'GitBranch', + iconColor: 'text-indigo-600', + bgColor: 'bg-indigo-50', + demandTier: 'C', + }, + { + slug: 'remove-watermark-pdf', + i18nKey: 'removeWatermark', + component: () => import('@/components/tools/RemoveWatermark'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'Droplets', + iconColor: 'text-rose-600', + bgColor: 'bg-rose-50', + demandTier: 'B', + }, + { + slug: 'reorder-pdf', + i18nKey: 'reorderPdf', + component: () => import('@/components/tools/ReorderPdf'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'ArrowUpDown', + iconColor: 'text-violet-600', + bgColor: 'bg-violet-50', + demandTier: 'B', + }, + { + slug: 'extract-pages', + i18nKey: 'extractPages', + component: () => import('@/components/tools/ExtractPages'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileOutput', + iconColor: 'text-amber-600', + bgColor: 'bg-amber-50', + demandTier: 'B', + }, + { + slug: 'sign-pdf', + i18nKey: 'signPdf', + component: () => import('@/components/tools/SignPdf'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'PenLine', + iconColor: 'text-emerald-600', + bgColor: 'bg-emerald-50', + demandTier: 'A', + }, + { + slug: 'crop-pdf', + i18nKey: 'cropPdf', + component: () => import('@/components/tools/CropPdf'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'Crop', + iconColor: 'text-orange-600', + bgColor: 'bg-orange-50', + demandTier: 'B', + }, + { + slug: 'flatten-pdf', + i18nKey: 'flattenPdf', + component: () => import('@/components/tools/FlattenPdf'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileDown', + iconColor: 'text-slate-600', + bgColor: 'bg-slate-50', + demandTier: 'B', + }, + { + slug: 'repair-pdf', + i18nKey: 'repairPdf', + component: () => import('@/components/tools/RepairPdf'), + category: 'pdf-extended', + homepage: true, + homepageSection: 'pdf', + iconName: 'Wrench', + iconColor: 'text-yellow-600', + bgColor: 'bg-yellow-50', + demandTier: 'B', + }, + { + slug: 'pdf-metadata', + i18nKey: 'pdfMetadata', + component: () => import('@/components/tools/PdfMetadata'), + category: 'pdf-extended', + homepage: false, + homepageSection: 'pdf', + iconName: 'FileText', + iconColor: 'text-gray-600', + bgColor: 'bg-gray-50', + demandTier: 'C', + }, + + // ─── Image ───────────────────────────────────────────────────── + { + slug: 'image-converter', + i18nKey: 'imageConvert', + component: () => import('@/components/tools/ImageConverter'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'ImageIcon', + iconColor: 'text-purple-600', + bgColor: 'bg-purple-50', + demandTier: 'B', + }, + { + slug: 'image-resize', + i18nKey: 'imageResize', + component: () => import('@/components/tools/ImageResize'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'Scaling', + iconColor: 'text-teal-600', + bgColor: 'bg-teal-50', + demandTier: 'B', + }, + { + slug: 'compress-image', + i18nKey: 'compressImage', + component: () => import('@/components/tools/CompressImage'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'Minimize2', + iconColor: 'text-orange-600', + bgColor: 'bg-orange-50', + demandTier: 'A', + }, + { + slug: 'ocr', + i18nKey: 'ocr', + component: () => import('@/components/tools/OcrTool'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'ScanText', + iconColor: 'text-amber-600', + bgColor: 'bg-amber-50', + demandTier: 'A', + }, + { + slug: 'remove-background', + i18nKey: 'removeBg', + component: () => import('@/components/tools/RemoveBackground'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'Eraser', + iconColor: 'text-fuchsia-600', + bgColor: 'bg-fuchsia-50', + demandTier: 'A', + }, + { + slug: 'image-to-svg', + i18nKey: 'imageToSvg', + component: () => import('@/components/tools/ImageToSvg'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'ImageIcon', + iconColor: 'text-indigo-600', + bgColor: 'bg-indigo-50', + demandTier: 'B', + }, + { + slug: 'image-crop', + i18nKey: 'imageCrop', + component: () => import('@/components/tools/ImageCrop'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'Crop', + iconColor: 'text-pink-600', + bgColor: 'bg-pink-50', + demandTier: 'C', + }, + { + slug: 'image-rotate-flip', + i18nKey: 'imageRotateFlip', + component: () => import('@/components/tools/ImageRotateFlip'), + category: 'image', + homepage: true, + homepageSection: 'other', + iconName: 'RotateCw', + iconColor: 'text-cyan-600', + bgColor: 'bg-cyan-50', + demandTier: 'C', + }, + + // ─── Conversion ──────────────────────────────────────────────── + { + slug: 'pdf-to-excel', + i18nKey: 'pdfToExcel', + component: () => import('@/components/tools/PdfToExcel'), + category: 'conversion', + homepage: true, + homepageSection: 'pdf', + iconName: 'Sheet', + iconColor: 'text-green-600', + bgColor: 'bg-green-50', + demandTier: 'A', + }, + { + slug: 'html-to-pdf', + i18nKey: 'htmlToPdf', + component: () => import('@/components/tools/HtmlToPdf'), + category: 'conversion', + homepage: true, + homepageSection: 'other', + iconName: 'Code', + iconColor: 'text-sky-600', + bgColor: 'bg-sky-50', + demandTier: 'B', + }, + { + slug: 'pdf-to-pptx', + i18nKey: 'pdfToPptx', + component: () => import('@/components/tools/PdfToPptx'), + category: 'conversion', + homepage: true, + homepageSection: 'other', + iconName: 'Presentation', + iconColor: 'text-orange-600', + bgColor: 'bg-orange-50', + demandTier: 'B', + }, + { + slug: 'excel-to-pdf', + i18nKey: 'excelToPdf', + component: () => import('@/components/tools/ExcelToPdf'), + category: 'conversion', + homepage: true, + homepageSection: 'other', + iconName: 'Sheet', + iconColor: 'text-emerald-600', + bgColor: 'bg-emerald-50', + demandTier: 'B', + }, + { + slug: 'pptx-to-pdf', + i18nKey: 'pptxToPdf', + component: () => import('@/components/tools/PptxToPdf'), + category: 'conversion', + homepage: true, + homepageSection: 'other', + iconName: 'Presentation', + iconColor: 'text-red-600', + bgColor: 'bg-red-50', + demandTier: 'B', + }, + + // ─── AI ──────────────────────────────────────────────────────── + { + slug: 'chat-pdf', + i18nKey: 'chatPdf', + component: () => import('@/components/tools/ChatPdf'), + category: 'ai', + homepage: true, + homepageSection: 'pdf', + iconName: 'MessageSquare', + iconColor: 'text-blue-600', + bgColor: 'bg-blue-50', + demandTier: 'B', + }, + { + slug: 'summarize-pdf', + i18nKey: 'summarizePdf', + component: () => import('@/components/tools/SummarizePdf'), + category: 'ai', + homepage: true, + homepageSection: 'pdf', + iconName: 'FileText', + iconColor: 'text-emerald-600', + bgColor: 'bg-emerald-50', + demandTier: 'A', + }, + { + slug: 'translate-pdf', + i18nKey: 'translatePdf', + component: () => import('@/components/tools/TranslatePdf'), + category: 'ai', + homepage: true, + homepageSection: 'pdf', + iconName: 'Languages', + iconColor: 'text-purple-600', + bgColor: 'bg-purple-50', + demandTier: 'A', + }, + { + slug: 'extract-tables', + i18nKey: 'tableExtractor', + component: () => import('@/components/tools/TableExtractor'), + category: 'ai', + homepage: true, + homepageSection: 'pdf', + iconName: 'Table', + iconColor: 'text-teal-600', + bgColor: 'bg-teal-50', + demandTier: 'B', + }, + + // ─── Utility ─────────────────────────────────────────────────── + { + slug: 'qr-code', + i18nKey: 'qrCode', + component: () => import('@/components/tools/QrCodeGenerator'), + category: 'utility', + homepage: true, + homepageSection: 'other', + iconName: 'QrCode', + iconColor: 'text-indigo-600', + bgColor: 'bg-indigo-50', + demandTier: 'B', + }, + { + slug: 'barcode-generator', + i18nKey: 'barcode', + component: () => import('@/components/tools/BarcodeGenerator'), + category: 'utility', + homepage: true, + homepageSection: 'other', + iconName: 'Barcode', + iconColor: 'text-gray-600', + bgColor: 'bg-gray-50', + demandTier: 'B', + }, + { + slug: 'video-to-gif', + i18nKey: 'videoToGif', + component: () => import('@/components/tools/VideoToGif'), + category: 'utility', + homepage: true, + homepageSection: 'other', + iconName: 'Film', + iconColor: 'text-emerald-600', + bgColor: 'bg-emerald-50', + demandTier: 'B', + }, + { + slug: 'word-counter', + i18nKey: 'wordCounter', + component: () => import('@/components/tools/WordCounter'), + category: 'utility', + homepage: true, + homepageSection: 'other', + iconName: 'Hash', + iconColor: 'text-blue-600', + bgColor: 'bg-blue-50', + demandTier: 'C', + }, + { + slug: 'text-cleaner', + i18nKey: 'textCleaner', + component: () => import('@/components/tools/TextCleaner'), + category: 'utility', + homepage: true, + homepageSection: 'other', + iconName: 'Eraser', + iconColor: 'text-indigo-600', + bgColor: 'bg-indigo-50', + demandTier: 'C', + }, +] as const; + +// ── Derived helpers ──────────────────────────────────────────────── + +/** All tool slugs — usable by routes.ts, sitemap, etc. */ +export function getManifestSlugs(): string[] { + return TOOL_MANIFEST.map((t) => t.slug); +} + +/** Tools visible on the homepage, split by section */ +export function getHomepageTools(section: 'pdf' | 'other'): readonly ToolEntry[] { + return TOOL_MANIFEST.filter((t) => t.homepage && t.homepageSection === section); +} + +/** Lookup a single tool by slug */ +export function getToolEntry(slug: string): ToolEntry | undefined { + return TOOL_MANIFEST.find((t) => t.slug === slug); +} + +/** All tool route paths — for the route registry */ +export function getManifestRoutePaths(): string[] { + return TOOL_MANIFEST.map((t) => `/tools/${t.slug}`); +} diff --git a/frontend/src/i18n/ar.json b/frontend/src/i18n/ar.json index 1d38032..f3d2966 100644 --- a/frontend/src/i18n/ar.json +++ b/frontend/src/i18n/ar.json @@ -326,6 +326,18 @@ "وصول API" ], "featureCompare": "مقارنة الميزات", + "features": { + "credits": "الرصيد لكل نافذة", + "apiAccess": "الوصول عبر API", + "apiRequests": "طلبات API", + "maxFileSize": "الحد الأقصى لحجم الملف", + "historyRetention": "حفظ السجل", + "allTools": "جميع الأدوات (44)", + "aiTools": "أدوات الذكاء الاصطناعي", + "priorityProcessing": "المعالجة ذات الأولوية", + "noAds": "بدون إعلانات", + "emailSupport": "دعم عبر البريد الإلكتروني" + }, "faqTitle": "الأسئلة الشائعة", "faq": [ { @@ -971,6 +983,8 @@ "webQuotaTitle": "مهام الويب هذا الشهر", "apiQuotaTitle": "مهام API هذا الشهر", "quotaPeriod": "الفترة", + "creditBalanceTitle": "رصيد الاستخدام", + "creditWindowResets": "يتجدد في", "apiKeysTitle": "مفاتيح API", "apiKeysSubtitle": "أدر مفاتيح B2B API. كل مفتاح يمنحك وصولاً متزامناً بمستوى برو لجميع الأدوات.", "apiKeyNamePlaceholder": "اسم المفتاح (مثال: إنتاج)", @@ -1017,6 +1031,17 @@ "downloadReady": "ملفك جاهز للتحميل.", "linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة." }, + "downloadGate": { + "title": "سجّل لتحميل ملفك", + "subtitle": "ملفك جاهز. أنشئ حسابًا مجانيًا لتحميله.", + "benefit1": "حمّل ملفاتك المعالجة فورًا", + "benefit2": "50 رصيدًا مجانيًا كل 30 يومًا", + "benefit3": "الوصول إلى جميع الأدوات بدون قيود", + "createAccount": "إنشاء حساب مجاني", + "signIn": "لديك حساب بالفعل؟ سجّل الدخول", + "switchToRegister": "ليس لديك حساب؟ أنشئ واحدًا", + "downloadCta": "سجّل لتحميل الملف" + }, "seo": { "headings": { "whatItDoes": "ما تفعله هذه الأداة", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 72b41c0..9f1b8f4 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -326,6 +326,18 @@ "API access" ], "featureCompare": "Feature Comparison", + "features": { + "credits": "Credits per window", + "apiAccess": "API access", + "apiRequests": "API requests", + "maxFileSize": "Max file size", + "historyRetention": "History retention", + "allTools": "All 44 tools", + "aiTools": "AI tools included", + "priorityProcessing": "Priority processing", + "noAds": "No ads", + "emailSupport": "Email support" + }, "faqTitle": "Frequently Asked Questions", "faq": [ { @@ -971,6 +983,8 @@ "webQuotaTitle": "Web Tasks This Month", "apiQuotaTitle": "API Tasks This Month", "quotaPeriod": "Period", + "creditBalanceTitle": "Credit Balance", + "creditWindowResets": "Resets on", "apiKeysTitle": "API Keys", "apiKeysSubtitle": "Manage your B2B API keys. Each key gives Pro-level async access to all tools.", "apiKeyNamePlaceholder": "Key name (e.g. Production)", @@ -1017,6 +1031,17 @@ "downloadReady": "Your file is ready for download.", "linkExpiry": "Download link expires in 30 minutes." }, + "downloadGate": { + "title": "Sign up to download your file", + "subtitle": "Your file is processed and ready. Create a free account to download it.", + "benefit1": "Download your processed files instantly", + "benefit2": "50 free credits every 30 days", + "benefit3": "Access to all tools with no restrictions", + "createAccount": "Create Free Account", + "signIn": "Already have an account? Sign in", + "switchToRegister": "Don't have an account? Create one", + "downloadCta": "Sign up to download" + }, "seo": { "headings": { "whatItDoes": "What This Tool Does", diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 582b345..41d6108 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -326,6 +326,18 @@ "Accès API" ], "featureCompare": "Comparaison des fonctionnalités", + "features": { + "credits": "Crédits par fenêtre", + "apiAccess": "Accès API", + "apiRequests": "Requêtes API", + "maxFileSize": "Taille max. de fichier", + "historyRetention": "Conservation de l'historique", + "allTools": "Tous les 44 outils", + "aiTools": "Outils IA inclus", + "priorityProcessing": "Traitement prioritaire", + "noAds": "Sans publicité", + "emailSupport": "Support par e-mail" + }, "faqTitle": "Questions fréquentes", "faq": [ { @@ -971,6 +983,8 @@ "webQuotaTitle": "Tâches web ce mois-ci", "apiQuotaTitle": "Tâches API ce mois-ci", "quotaPeriod": "Période", + "creditBalanceTitle": "Solde de crédits", + "creditWindowResets": "Se renouvelle le", "apiKeysTitle": "Clés API", "apiKeysSubtitle": "Gérez vos clés API B2B. Chaque clé donne un accès asynchrone Pro à tous les outils.", "apiKeyNamePlaceholder": "Nom de la clé (ex. Production)", @@ -1017,6 +1031,17 @@ "downloadReady": "Votre fichier est prêt à être téléchargé.", "linkExpiry": "Le lien de téléchargement expire dans 30 minutes." }, + "downloadGate": { + "title": "Inscrivez-vous pour télécharger votre fichier", + "subtitle": "Votre fichier est traité et prêt. Créez un compte gratuit pour le télécharger.", + "benefit1": "Téléchargez vos fichiers traités instantanément", + "benefit2": "50 crédits gratuits tous les 30 jours", + "benefit3": "Accès à tous les outils sans restrictions", + "createAccount": "Créer un compte gratuit", + "signIn": "Vous avez déjà un compte ? Connectez-vous", + "switchToRegister": "Pas de compte ? Créez-en un", + "downloadCta": "Inscrivez-vous pour télécharger" + }, "seo": { "headings": { "whatItDoes": "Ce que fait cet outil", diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index a3a4dfc..16be042 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -356,28 +356,30 @@ export default function AccountPage() { - {/* Usage / Quota Cards */} - {usage && ( + {/* Credit Balance Cards */} + {usage && usage.credits && (

- {t('account.webQuotaTitle')} + {t('account.creditBalanceTitle')}

- {usage.web_quota.used} - / {usage.web_quota.limit ?? '∞'} + {usage.credits.credits_remaining} + / {usage.credits.credits_allocated}

- {usage.web_quota.limit != null && ( -
-
-
+
+
+
+ {usage.credits.window_end && ( +

+ {t('account.creditWindowResets')}: {new Date(usage.credits.window_end).toLocaleDateString()} +

)} -

{t('account.quotaPeriod')}: {usage.period_month}

- {usage.api_quota.limit != null && ( + {usage.api_quota?.limit != null && (

{t('account.apiQuotaTitle')} @@ -392,7 +394,6 @@ export default function AccountPage() { style={{ width: `${Math.min(100, (usage.api_quota.used / usage.api_quota.limit) * 100)}%` }} />

-

{t('account.quotaPeriod')}: {usage.period_month}

)}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1bfc329..522aa53 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -33,11 +33,31 @@ import { Table, Search, X, + Crop, + FileDown, + Wrench, + Presentation, + Barcode, } from 'lucide-react'; import ToolCard from '@/components/shared/ToolCard'; import HeroUploadZone from '@/components/shared/HeroUploadZone'; import AdSlot from '@/components/layout/AdSlot'; import SocialProofStrip from '@/components/shared/SocialProofStrip'; +import { getHomepageTools, type ToolEntry } from '@/config/toolManifest'; + +// Map icon names from manifest to lucide components +const ICON_MAP: Record> = { + FileText, FileOutput, Minimize2, ImageIcon, Film, Hash, Eraser, Layers, + Scissors, RotateCw, Image, FileImage, Droplets, Lock, Unlock, ListOrdered, + PenLine, GitBranch, Scaling, ScanText, Sheet, ArrowUpDown, QrCode, Code, + MessageSquare, Languages, Table, Crop, FileDown, Wrench, Presentation, Barcode, +}; + +function renderToolIcon(tool: ToolEntry) { + const IconComponent = ICON_MAP[tool.iconName]; + if (!IconComponent) return null; + return ; +} interface ToolInfo { key: string; @@ -46,44 +66,17 @@ interface ToolInfo { bgColor: string; } -const pdfTools: ToolInfo[] = [ - { key: 'pdfEditor', path: '/tools/pdf-editor', icon: , bgColor: 'bg-rose-50' }, - { key: 'pdfToWord', path: '/tools/pdf-to-word', icon: , bgColor: 'bg-red-50' }, - { key: 'wordToPdf', path: '/tools/word-to-pdf', icon: , bgColor: 'bg-blue-50' }, - { key: 'compressPdf', path: '/tools/compress-pdf', icon: , bgColor: 'bg-orange-50' }, - { key: 'mergePdf', path: '/tools/merge-pdf', icon: , bgColor: 'bg-violet-50' }, - { key: 'splitPdf', path: '/tools/split-pdf', icon: , bgColor: 'bg-pink-50' }, - { key: 'rotatePdf', path: '/tools/rotate-pdf', icon: , bgColor: 'bg-teal-50' }, - { key: 'pdfToImages', path: '/tools/pdf-to-images', icon: , bgColor: 'bg-amber-50' }, - { key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: , bgColor: 'bg-lime-50' }, - { key: 'watermarkPdf', path: '/tools/watermark-pdf', icon: , bgColor: 'bg-cyan-50' }, - { key: 'protectPdf', path: '/tools/protect-pdf', icon: , bgColor: 'bg-red-50' }, - { key: 'unlockPdf', path: '/tools/unlock-pdf', icon: , bgColor: 'bg-green-50' }, - { key: 'pageNumbers', path: '/tools/page-numbers', icon: , bgColor: 'bg-sky-50' }, - { key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: , bgColor: 'bg-indigo-50' }, - { key: 'pdfToExcel', path: '/tools/pdf-to-excel', icon: , bgColor: 'bg-green-50' }, - { key: 'removeWatermark', path: '/tools/remove-watermark-pdf', icon: , bgColor: 'bg-rose-50' }, - { key: 'reorderPdf', path: '/tools/reorder-pdf', icon: , bgColor: 'bg-violet-50' }, - { key: 'extractPages', path: '/tools/extract-pages', icon: , bgColor: 'bg-amber-50' }, - { key: 'chatPdf', path: '/tools/chat-pdf', icon: , bgColor: 'bg-blue-50' }, - { key: 'summarizePdf', path: '/tools/summarize-pdf', icon: , bgColor: 'bg-emerald-50' }, - { key: 'translatePdf', path: '/tools/translate-pdf', icon: , bgColor: 'bg-purple-50' }, - { key: 'tableExtractor', path: '/tools/extract-tables', icon: , bgColor: 'bg-teal-50' }, -]; +function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] { + return tools.map((t) => ({ + key: t.i18nKey, + path: `/tools/${t.slug}`, + icon: renderToolIcon(t), + bgColor: t.bgColor, + })); +} -const otherTools: ToolInfo[] = [ - { key: 'imageConvert', path: '/tools/image-converter', icon: , bgColor: 'bg-purple-50' }, - { key: 'imageResize', path: '/tools/image-resize', icon: , bgColor: 'bg-teal-50' }, - { key: 'compressImage', path: '/tools/compress-image', icon: , bgColor: 'bg-orange-50' }, - { key: 'ocr', path: '/tools/ocr', icon: , bgColor: 'bg-amber-50' }, - { key: 'removeBg', path: '/tools/remove-background', icon: , bgColor: 'bg-fuchsia-50' }, - { key: 'imageToSvg', path: '/tools/image-to-svg', icon: , bgColor: 'bg-indigo-50' }, - { key: 'videoToGif', path: '/tools/video-to-gif', icon: , bgColor: 'bg-emerald-50' }, - { key: 'qrCode', path: '/tools/qr-code', icon: , bgColor: 'bg-indigo-50' }, - { key: 'htmlToPdf', path: '/tools/html-to-pdf', icon: , bgColor: 'bg-sky-50' }, - { key: 'wordCounter', path: '/tools/word-counter', icon: , bgColor: 'bg-blue-50' }, - { key: 'textCleaner', path: '/tools/text-cleaner', icon: , bgColor: 'bg-indigo-50' }, -]; +const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf')); +const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other')); export default function HomePage() { const { t } = useTranslation(); diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx index e6addff..e71e953 100644 --- a/frontend/src/pages/PricingPage.tsx +++ b/frontend/src/pages/PricingPage.tsx @@ -18,7 +18,7 @@ interface PlanFeature { } const FEATURES: PlanFeature[] = [ - { key: 'webRequests', free: '50/month', pro: '500/month' }, + { key: 'credits', free: '50 credits/30 days', pro: '500 credits/30 days' }, { key: 'apiAccess', free: false, pro: true }, { key: 'apiRequests', free: '—', pro: '1,000/month' }, { key: 'maxFileSize', free: '50 MB', pro: '100 MB' }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b7b1854..3437ee6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -479,6 +479,17 @@ export async function logoutUser(): Promise { await ensureCsrfToken(true); } +/** + * Claim an anonymous task into the authenticated user's history. + */ +export async function claimTask(taskId: string, tool: string): Promise<{ claimed: boolean }> { + const response = await api.post<{ claimed: boolean }>('/account/claim-task', { + task_id: taskId, + tool, + }); + return response.data; +} + /** * Return the current authenticated user, if any. */ @@ -965,9 +976,18 @@ export async function updateAdminUserRole(userId: number, role: string): Promise // --- Account / Usage / API Keys --- +export interface CreditInfo { + credits_allocated: number; + credits_used: number; + credits_remaining: number; + window_start: string | null; + window_end: string | null; + plan: string; +} + export interface UsageSummary { plan: string; - period_month: string; + period_month?: string; ads_enabled: boolean; history_limit: number; file_limits_mb: { @@ -977,8 +997,10 @@ export interface UsageSummary { video: number; homepageSmartUpload: number; }; + credits: CreditInfo; + tool_costs: Record; web_quota: { used: number; limit: number | null }; - api_quota: { used: number; limit: number | null }; + api_quota?: { used: number; limit: number | null }; } export interface ApiKey { diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts index 21ffcea..0502e17 100644 --- a/frontend/src/utils/seo.ts +++ b/frontend/src/utils/seo.ts @@ -10,6 +10,8 @@ export interface ToolSeoData { ratingValue?: number; ratingCount?: number; features?: string[]; + /** Optional HowTo steps for inline HowTo within the tool schema */ + howToSteps?: string[]; } export interface LanguageAlternate { @@ -81,12 +83,23 @@ export function generateToolSchema(tool: ToolSeoData): object { operatingSystem: 'Any', browserRequirements: 'Requires JavaScript. Works in modern browsers.', isAccessibleForFree: true, - offers: { - '@type': 'Offer', - price: '0', - priceCurrency: 'USD', - availability: 'https://schema.org/InStock', - }, + offers: [ + { + '@type': 'Offer', + name: 'Free', + price: '0', + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + }, + { + '@type': 'Offer', + name: 'Pro', + price: '9.99', + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + description: 'Pro plan — higher limits, no ads, API access', + }, + ], description: tool.description, inLanguage: ['en', 'ar', 'fr'], provider: { @@ -94,6 +107,12 @@ export function generateToolSchema(tool: ToolSeoData): object { name: DEFAULT_SITE_NAME, url: getSiteOrigin(), }, + potentialAction: { + '@type': 'UseAction', + target: tool.url, + name: `Use ${tool.name}`, + }, + screenshot: `${getSiteOrigin()}/social-preview.svg`, }; if (tool.features && tool.features.length > 0) {