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
This commit is contained in:
Your Name
2026-04-01 22:22:48 +02:00
parent 3e1c0e5f99
commit 314f847ece
49 changed files with 2142 additions and 361 deletions

View File

@@ -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<RouteName>` (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 <describe the module>."""`
- Then imports, then the test class(es).
- Suggest the save path as a comment at the top: `# Save as: backend/tests/test_<module_name>.py`
- Do **not** add `if __name__ == "__main__"` blocks.
{{argument}}

View File

@@ -70,7 +70,7 @@ def init_celery(app):
"app.tasks.pdf_to_excel_tasks.*": {"queue": "pdf_tools"}, "app.tasks.pdf_to_excel_tasks.*": {"queue": "pdf_tools"},
"app.tasks.qrcode_tasks.*": {"queue": "default"}, "app.tasks.qrcode_tasks.*": {"queue": "default"},
"app.tasks.html_to_pdf_tasks.*": {"queue": "convert"}, "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_convert_tasks.*": {"queue": "convert"},
"app.tasks.pdf_extra_tasks.*": {"queue": "pdf_tools"}, "app.tasks.pdf_extra_tasks.*": {"queue": "pdf_tools"},
"app.tasks.image_extra_tasks.*": {"queue": "image"}, "app.tasks.image_extra_tasks.*": {"queue": "image"},

View File

@@ -6,15 +6,24 @@ from app.extensions import limiter
from app.services.account_service import ( from app.services.account_service import (
create_api_key, create_api_key,
get_user_by_id, get_user_by_id,
has_task_access,
list_api_keys, list_api_keys,
record_usage_event,
revoke_api_key, revoke_api_key,
) )
from app.services.policy_service import get_usage_summary_for_user 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 ( from app.services.stripe_service import (
is_stripe_configured, is_stripe_configured,
get_stripe_price_id, 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 stripe
import logging import logging
@@ -38,6 +47,19 @@ def get_usage_route():
return jsonify(get_usage_summary_for_user(user_id, user["plan"])), 200 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"]) @account_bp.route("/subscription", methods=["GET"])
@limiter.limit("60/hour") @limiter.limit("60/hour")
def get_subscription_status(): 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({"error": "API key not found or already revoked."}), 404
return jsonify({"message": "API key revoked."}), 200 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

View File

@@ -52,7 +52,7 @@ def generate_barcode_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="barcode")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -38,7 +38,7 @@ def compress_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="compress-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -43,7 +43,7 @@ def compress_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="compress-image")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -33,7 +33,7 @@ def pdf_to_word_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-to-word")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -75,7 +75,7 @@ def word_to_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="word-to-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -34,6 +34,13 @@ def download_file(task_id: str, filename: str):
assert_api_task_access(actor, task_id) assert_api_task_access(actor, task_id)
else: else:
actor = resolve_web_actor() 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) assert_web_task_access(actor, task_id)
except PolicyError as exc: except PolicyError as exc:
abort(exc.status_code, exc.message) abort(exc.status_code, exc.message)

View File

@@ -39,7 +39,7 @@ def extract_flowchart_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-flowchart")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -75,7 +75,7 @@ def extract_sample_flowchart_route():
""" """
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-flowchart-sample")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -34,7 +34,7 @@ def html_to_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="html-to-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -54,7 +54,7 @@ def convert_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="image-convert")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -127,7 +127,7 @@ def resize_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="image-resize")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -180,7 +180,7 @@ def convert_image_to_svg_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="image-to-svg")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -54,7 +54,7 @@ def crop_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="image-crop")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -120,7 +120,7 @@ def rotate_flip_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="image-rotate-flip")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -52,7 +52,7 @@ def ocr_image_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="ocr-image")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -102,7 +102,7 @@ def ocr_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="ocr-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -11,6 +11,10 @@ from app.services.policy_service import (
resolve_web_actor, resolve_web_actor,
validate_actor_file, validate_actor_file,
) )
from app.services.translation_guardrails import (
check_page_admission,
TranslationAdmissionError,
)
from app.utils.file_validator import FileValidationError from app.utils.file_validator import FileValidationError
from app.utils.sanitizer import generate_safe_path from app.utils.sanitizer import generate_safe_path
from app.tasks.pdf_ai_tasks import ( from app.tasks.pdf_ai_tasks import (
@@ -48,7 +52,7 @@ def chat_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="chat-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -104,7 +108,7 @@ def summarize_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="summarize-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -161,7 +165,7 @@ def translate_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="translate-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code 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") task_id, input_path = generate_safe_path(ext, folder_type="upload")
file.save(input_path) 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( task = translate_pdf_task.delay(
input_path, input_path,
task_id, task_id,
@@ -213,7 +223,7 @@ def extract_tables_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="extract-tables")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -40,7 +40,7 @@ def pdf_to_pptx_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-to-pptx")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -77,7 +77,7 @@ def excel_to_pdf_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="excel-to-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -116,7 +116,7 @@ def pptx_to_pdf_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pptx-to-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -167,7 +167,7 @@ def sign_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="sign-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -54,7 +54,7 @@ def edit_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-edit")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -41,7 +41,7 @@ def crop_pdf_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="crop-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -89,7 +89,7 @@ def flatten_pdf_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="flatten-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -126,7 +126,7 @@ def repair_pdf_route():
file = request.files["file"] file = request.files["file"]
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="repair-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -184,7 +184,7 @@ def edit_metadata_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="edit-metadata")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -34,7 +34,7 @@ def pdf_to_excel_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-to-excel")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -56,7 +56,7 @@ def merge_pdfs_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="merge-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -123,7 +123,7 @@ def split_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="split-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -183,7 +183,7 @@ def rotate_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="rotate-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -246,7 +246,7 @@ def add_page_numbers_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="page-numbers")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -304,7 +304,7 @@ def pdf_to_images_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="pdf-to-images")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -353,7 +353,7 @@ def images_to_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="images-to-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -424,7 +424,7 @@ def watermark_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="watermark-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -480,7 +480,7 @@ def protect_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="protect-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -532,7 +532,7 @@ def unlock_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="unlock-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -579,7 +579,7 @@ def remove_watermark_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="remove-watermark")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -638,7 +638,7 @@ def reorder_pdf_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="reorder-pdf")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code
@@ -690,7 +690,7 @@ def extract_pages_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="extract-pages")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -45,7 +45,7 @@ def generate_qr_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="qr-code")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -38,7 +38,7 @@ def remove_bg_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="remove-bg")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -59,7 +59,7 @@ def video_to_gif_route():
actor = resolve_web_actor() actor = resolve_web_actor()
try: try:
assert_quota_available(actor) assert_quota_available(actor, tool="video-frames")
except PolicyError as e: except PolicyError as e:
return jsonify({"error": e.message}), e.status_code return jsonify({"error": e.message}), e.status_code

View File

@@ -228,6 +228,30 @@ def _init_postgres_tables(conn):
ON file_events(created_at DESC) 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): def _init_sqlite_tables(conn):
conn.executescript( conn.executescript(
@@ -316,6 +340,21 @@ def _init_sqlite_tables(conn):
CREATE INDEX IF NOT EXISTS idx_file_events_created CREATE INDEX IF NOT EXISTS idx_file_events_created
ON file_events(created_at DESC); 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 ''") conn.execute("ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''")
if not _column_exists(conn, "users", "role"): if not _column_exists(conn, "users", "role"):
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'") 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: def create_user(email: str, password: str) -> dict:
@@ -842,6 +883,7 @@ def record_usage_event(
task_id: str, task_id: str,
event_type: str, event_type: str,
api_key_id: int | None = None, api_key_id: int | None = None,
cost_points: int = 1,
): ):
if user_id is None: if user_id is None:
return return
@@ -851,17 +893,17 @@ def record_usage_event(
""" """
INSERT INTO usage_events ( INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id, 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() if is_postgres()
else """ else """
INSERT INTO usage_events ( INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id, 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( execute_query(
@@ -876,6 +918,7 @@ def record_usage_event(
event_type, event_type,
_utc_now(), _utc_now(),
get_current_period_month(), get_current_period_month(),
cost_points,
), ),
) )

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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."
)

View File

@@ -12,6 +12,23 @@ from app.services.account_service import (
normalize_plan, normalize_plan,
record_usage_event, 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 get_current_user_id, logout_user_session
from app.utils.auth import has_session_task_access, remember_task_access from app.utils.auth import has_session_task_access, remember_task_access
from app.utils.file_validator import validate_file from app.utils.file_validator import validate_file
@@ -19,10 +36,6 @@ from app.utils.file_validator import validate_file
FREE_PLAN = "free" FREE_PLAN = "free"
PRO_PLAN = "pro" PRO_PLAN = "pro"
FREE_WEB_MONTHLY_LIMIT = 50
PRO_WEB_MONTHLY_LIMIT = 500
PRO_API_MONTHLY_LIMIT = 1000
FREE_HISTORY_LIMIT = 25 FREE_HISTORY_LIMIT = 25
PRO_HISTORY_LIMIT = 250 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: 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": if actor_type == "anonymous":
return None 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: def get_api_quota_limit(plan: str) -> int | None:
"""Return the monthly accepted-task cap for one API actor.""" """Return the credit allocation for one API actor's window."""
return PRO_API_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else None return PRO_API_CREDITS_PER_WINDOW if normalize_plan(plan) == PRO_PLAN else None
def ads_enabled(plan: str, actor_type: str) -> bool: 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: def get_usage_summary_for_user(user_id: int, plan: str) -> dict:
"""Return usage/quota summary for one authenticated user.""" """Return usage/quota summary for one authenticated user."""
normalized_plan = normalize_plan(plan) normalized_plan = normalize_plan(plan)
current_period = get_current_period_month() credit_info = get_credit_summary(user_id, normalized_plan)
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
)
return { return {
"plan": normalized_plan, "plan": normalized_plan,
"period_month": current_period,
"ads_enabled": ads_enabled(normalized_plan, "session"), "ads_enabled": ads_enabled(normalized_plan, "session"),
"history_limit": get_history_limit(normalized_plan), "history_limit": get_history_limit(normalized_plan),
"file_limits_mb": get_effective_file_size_limits_mb(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": { "web_quota": {
"used": web_used, "used": credit_info["credits_used"],
"limit": get_web_quota_limit(normalized_plan, "session"), "limit": credit_info["credits_allocated"],
},
"api_quota": {
"used": api_used,
"limit": get_api_quota_limit(normalized_plan),
}, },
} }
@@ -173,21 +178,38 @@ def validate_actor_file(file_storage, allowed_types: list[str], actor: ActorCont
) )
def assert_quota_available(actor: ActorContext): def assert_quota_available(actor: ActorContext, tool: str | None = None):
"""Ensure an actor still has accepted-task quota for the current month.""" """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: 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 return
if actor.source == "web": if actor.source == "web":
limit = get_web_quota_limit(actor.plan, actor.actor_type) # Credit-based check
if limit is None: cost = get_tool_credit_cost(tool) if tool else 1
return balance = get_rolling_balance(actor.user_id, actor.plan)
used = count_usage_events(actor.user_id, "web", event_type="accepted") if balance < cost:
if used >= limit:
if normalize_plan(actor.plan) == PRO_PLAN: 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( 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, 429,
) )
return return
@@ -202,10 +224,29 @@ def assert_quota_available(actor: ActorContext):
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): 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": if actor.source == "web":
remember_task_access(celery_task_id) 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( record_usage_event(
user_id=actor.user_id, user_id=actor.user_id,
source=actor.source, source=actor.source,
@@ -213,6 +254,7 @@ def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
task_id=celery_task_id, task_id=celery_task_id,
event_type="accepted", event_type="accepted",
api_key_id=actor.api_key_id, api_key_id=actor.api_key_id,
cost_points=cost,
) )

View File

@@ -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)

View File

@@ -15,6 +15,10 @@ from app.services.pdf_ai_service import (
PdfAiError, PdfAiError,
) )
from app.services.task_tracking_service import finalize_task_tracking 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 from app.utils.sanitizer import cleanup_task_files
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -214,9 +218,24 @@ def translate_pdf_task(
meta={"step": "Translating document with provider fallback..."}, meta={"step": "Translating document with provider fallback..."},
) )
data = translate_pdf( # ── Cache lookup — skip AI call if identical translation exists ──
input_path, target_language, source_language=source_language 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 = { result = {
"status": "completed", "status": "completed",

View File

@@ -46,7 +46,8 @@ class TestConfigEndpoint:
usage = data["usage"] usage = data["usage"]
assert usage["plan"] == "free" assert usage["plan"] == "free"
assert "web_quota" in usage 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): def test_max_upload_mb_is_correct(self, client):
"""max_upload_mb should equal the largest single-type limit.""" """max_upload_mb should equal the largest single-type limit."""

View File

@@ -1,12 +1,24 @@
"""Tests for file download route.""" """Tests for file download route."""
import os import os
from app.services.account_service import create_user
from app.utils.auth import TASK_ACCESS_SESSION_KEY from app.utils.auth import TASK_ACCESS_SESSION_KEY
class TestDownload: class TestDownload:
def test_download_nonexistent_file(self, client): def test_download_anonymous_returns_401(self, client):
"""Should return 404 for missing file.""" """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') response = client.get('/api/download/some-task-id/output.pdf')
assert response.status_code == 404 assert response.status_code == 404
@@ -22,10 +34,13 @@ class TestDownload:
assert response.status_code in (400, 404) assert response.status_code in (400, 404)
def test_download_valid_file(self, client, app): 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' task_id = 'test-download-id'
filename = 'output.pdf' filename = 'output.pdf'
with app.app_context():
user = create_user('download-valid@example.com', 'pass12345')
# Create the file in the output directory # Create the file in the output directory
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
@@ -34,6 +49,7 @@ class TestDownload:
f.write(b'%PDF-1.4 test content') f.write(b'%PDF-1.4 test content')
with client.session_transaction() as session: with client.session_transaction() as session:
session['user_id'] = user['id']
session[TASK_ACCESS_SESSION_KEY] = [task_id] session[TASK_ACCESS_SESSION_KEY] = [task_id]
response = client.get(f'/api/download/{task_id}/{filename}') response = client.get(f'/api/download/{task_id}/{filename}')
@@ -45,26 +61,37 @@ class TestDownload:
task_id = 'test-name-id' task_id = 'test-name-id'
filename = 'output.pdf' 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) output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, filename), 'wb') as f: with open(os.path.join(output_dir, filename), 'wb') as f:
f.write(b'%PDF-1.4') f.write(b'%PDF-1.4')
with client.session_transaction() as session: with client.session_transaction() as session:
session['user_id'] = user['id']
session[TASK_ACCESS_SESSION_KEY] = [task_id] session[TASK_ACCESS_SESSION_KEY] = [task_id]
response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf') response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf')
assert response.status_code == 200 assert response.status_code == 200
def test_download_requires_task_access(self, client, app): 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' task_id = 'protected-download-id'
filename = 'output.pdf' 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) output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, filename), 'wb') as f: with open(os.path.join(output_dir, filename), 'wb') as f:
f.write(b'%PDF-1.4 protected') 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}') response = client.get(f'/api/download/{task_id}/{filename}')
assert response.status_code == 404 assert response.status_code == 404

View File

@@ -60,7 +60,7 @@ services:
celery -A celery_worker.celery worker celery -A celery_worker.celery worker
--loglevel=warning --loglevel=warning
--concurrency=4 --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_file:
- .env - .env
environment: environment:

View File

@@ -65,7 +65,7 @@ services:
celery -A celery_worker.celery worker celery -A celery_worker.celery worker
--loglevel=info --loglevel=info
--concurrency=2 --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_file:
- .env - .env
environment: environment:

View File

@@ -9,6 +9,7 @@ import ToolLandingPage from '@/components/seo/ToolLandingPage';
import { useDirection } from '@/hooks/useDirection'; import { useDirection } from '@/hooks/useDirection';
import { initAnalytics, trackPageView } from '@/services/analytics'; import { initAnalytics, trackPageView } from '@/services/analytics';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { TOOL_MANIFEST } from '@/config/toolManifest';
let clarityInitialized = false; let clarityInitialized = false;
@@ -32,53 +33,10 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent')); const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant')); const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
// Tool Pages // Tool components — derived from manifest using React.lazy
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord')); const ToolComponents = Object.fromEntries(
const WordToPdf = lazy(() => import('@/components/tools/WordToPdf')); TOOL_MANIFEST.map((tool) => [tool.slug, lazy(tool.component)])
const PdfCompressor = lazy(() => import('@/components/tools/PdfCompressor')); ) as Record<string, React.LazyExoticComponent<React.ComponentType>>;
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'));
function LoadingFallback() { function LoadingFallback() {
return ( return (
@@ -165,71 +123,17 @@ export default function App() {
<Route path="/ar/:slug" element={<SeoRoutePage />} /> <Route path="/ar/:slug" element={<SeoRoutePage />} />
<Route path="/:slug" element={<SeoRoutePage />} /> <Route path="/:slug" element={<SeoRoutePage />} />
{/* PDF Tools */} {/* Tool Routes — driven by the unified manifest */}
<Route path="/tools/pdf-to-word" element={<ToolLandingPage slug="pdf-to-word"><PdfToWord /></ToolLandingPage>} /> {TOOL_MANIFEST.map((tool) => {
<Route path="/tools/word-to-pdf" element={<ToolLandingPage slug="word-to-pdf"><WordToPdf /></ToolLandingPage>} /> const Component = ToolComponents[tool.slug];
<Route path="/tools/compress-pdf" element={<ToolLandingPage slug="compress-pdf"><PdfCompressor /></ToolLandingPage>} /> return (
<Route path="/tools/merge-pdf" element={<ToolLandingPage slug="merge-pdf"><MergePdf /></ToolLandingPage>} /> <Route
<Route path="/tools/split-pdf" element={<ToolLandingPage slug="split-pdf"><SplitPdf /></ToolLandingPage>} /> key={tool.slug}
<Route path="/tools/rotate-pdf" element={<ToolLandingPage slug="rotate-pdf"><RotatePdf /></ToolLandingPage>} /> path={`/tools/${tool.slug}`}
<Route path="/tools/pdf-to-images" element={<ToolLandingPage slug="pdf-to-images"><PdfToImages /></ToolLandingPage>} /> element={<ToolLandingPage slug={tool.slug}><Component /></ToolLandingPage>}
<Route path="/tools/images-to-pdf" element={<ToolLandingPage slug="images-to-pdf"><ImagesToPdf /></ToolLandingPage>} /> />
<Route path="/tools/watermark-pdf" element={<ToolLandingPage slug="watermark-pdf"><WatermarkPdf /></ToolLandingPage>} /> );
<Route path="/tools/protect-pdf" element={<ToolLandingPage slug="protect-pdf"><ProtectPdf /></ToolLandingPage>} /> })}
<Route path="/tools/unlock-pdf" element={<ToolLandingPage slug="unlock-pdf"><UnlockPdf /></ToolLandingPage>} />
<Route path="/tools/page-numbers" element={<ToolLandingPage slug="page-numbers"><AddPageNumbers /></ToolLandingPage>} />
<Route path="/tools/pdf-editor" element={<ToolLandingPage slug="pdf-editor"><PdfEditor /></ToolLandingPage>} />
<Route path="/tools/pdf-flowchart" element={<ToolLandingPage slug="pdf-flowchart"><PdfFlowchart /></ToolLandingPage>} />
{/* Image Tools */}
<Route path="/tools/image-converter" element={<ToolLandingPage slug="image-converter"><ImageConverter /></ToolLandingPage>} />
<Route path="/tools/image-resize" element={<ToolLandingPage slug="image-resize"><ImageResize /></ToolLandingPage>} />
<Route path="/tools/compress-image" element={<ToolLandingPage slug="compress-image"><CompressImage /></ToolLandingPage>} />
<Route path="/tools/ocr" element={<ToolLandingPage slug="ocr"><OcrTool /></ToolLandingPage>} />
<Route path="/tools/remove-background" element={<ToolLandingPage slug="remove-background"><RemoveBackground /></ToolLandingPage>} />
<Route path="/tools/image-to-svg" element={<ToolLandingPage slug="image-to-svg"><ImageToSvg /></ToolLandingPage>} />
{/* Convert Tools */}
<Route path="/tools/pdf-to-excel" element={<ToolLandingPage slug="pdf-to-excel"><PdfToExcel /></ToolLandingPage>} />
<Route path="/tools/html-to-pdf" element={<ToolLandingPage slug="html-to-pdf"><HtmlToPdf /></ToolLandingPage>} />
{/* PDF Extra Tools */}
<Route path="/tools/remove-watermark-pdf" element={<ToolLandingPage slug="remove-watermark-pdf"><RemoveWatermark /></ToolLandingPage>} />
<Route path="/tools/reorder-pdf" element={<ToolLandingPage slug="reorder-pdf"><ReorderPdf /></ToolLandingPage>} />
<Route path="/tools/extract-pages" element={<ToolLandingPage slug="extract-pages"><ExtractPages /></ToolLandingPage>} />
{/* AI Tools */}
<Route path="/tools/chat-pdf" element={<ToolLandingPage slug="chat-pdf"><ChatPdf /></ToolLandingPage>} />
<Route path="/tools/summarize-pdf" element={<ToolLandingPage slug="summarize-pdf"><SummarizePdf /></ToolLandingPage>} />
<Route path="/tools/translate-pdf" element={<ToolLandingPage slug="translate-pdf"><TranslatePdf /></ToolLandingPage>} />
<Route path="/tools/extract-tables" element={<ToolLandingPage slug="extract-tables"><TableExtractor /></ToolLandingPage>} />
{/* Other Tools */}
<Route path="/tools/qr-code" element={<ToolLandingPage slug="qr-code"><QrCodeGenerator /></ToolLandingPage>} />
{/* Video Tools */}
<Route path="/tools/video-to-gif" element={<ToolLandingPage slug="video-to-gif"><VideoToGif /></ToolLandingPage>} />
{/* Text Tools */}
<Route path="/tools/word-counter" element={<ToolLandingPage slug="word-counter"><WordCounter /></ToolLandingPage>} />
<Route path="/tools/text-cleaner" element={<ToolLandingPage slug="text-cleaner"><TextCleaner /></ToolLandingPage>} />
{/* Phase 2 PDF Conversion */}
<Route path="/tools/pdf-to-pptx" element={<ToolLandingPage slug="pdf-to-pptx"><PdfToPptx /></ToolLandingPage>} />
<Route path="/tools/excel-to-pdf" element={<ToolLandingPage slug="excel-to-pdf"><ExcelToPdf /></ToolLandingPage>} />
<Route path="/tools/pptx-to-pdf" element={<ToolLandingPage slug="pptx-to-pdf"><PptxToPdf /></ToolLandingPage>} />
<Route path="/tools/sign-pdf" element={<ToolLandingPage slug="sign-pdf"><SignPdf /></ToolLandingPage>} />
{/* Phase 2 PDF Extra */}
<Route path="/tools/crop-pdf" element={<ToolLandingPage slug="crop-pdf"><CropPdf /></ToolLandingPage>} />
<Route path="/tools/flatten-pdf" element={<ToolLandingPage slug="flatten-pdf"><FlattenPdf /></ToolLandingPage>} />
<Route path="/tools/repair-pdf" element={<ToolLandingPage slug="repair-pdf"><RepairPdf /></ToolLandingPage>} />
<Route path="/tools/pdf-metadata" element={<ToolLandingPage slug="pdf-metadata"><PdfMetadata /></ToolLandingPage>} />
{/* Phase 2 Image & Utility */}
<Route path="/tools/image-crop" element={<ToolLandingPage slug="image-crop"><ImageCrop /></ToolLandingPage>} />
<Route path="/tools/image-rotate-flip" element={<ToolLandingPage slug="image-rotate-flip"><ImageRotateFlip /></ToolLandingPage>} />
<Route path="/tools/barcode-generator" element={<ToolLandingPage slug="barcode-generator"><BarcodeGenerator /></ToolLandingPage>} />
{/* 404 */} {/* 404 */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />

View File

@@ -1,12 +1,15 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom'; 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 type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools'; import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt'; import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
import SharePanel from '@/components/shared/SharePanel'; import SharePanel from '@/components/shared/SharePanel';
import SuggestedTools from '@/components/seo/SuggestedTools'; import SuggestedTools from '@/components/seo/SuggestedTools';
import SignUpToDownloadModal from '@/components/shared/SignUpToDownloadModal';
import { useAuthStore } from '@/stores/authStore';
interface DownloadButtonProps { interface DownloadButtonProps {
/** Task result containing download URL */ /** Task result containing download URL */
@@ -18,10 +21,21 @@ interface DownloadButtonProps {
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) { export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user);
const [showGateModal, setShowGateModal] = useState(false);
const currentToolSlug = location.pathname.startsWith('/tools/') const currentToolSlug = location.pathname.startsWith('/tools/')
? location.pathname.replace('/tools/', '') ? location.pathname.replace('/tools/', '')
: null; : null;
// Extract the download task ID from the download URL path
// URL format: /api/download/<task_id>/<filename>
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 = () => { const handleDownloadClick = () => {
trackEvent('download_clicked', { filename: result.filename || 'unknown' }); trackEvent('download_clicked', { filename: result.filename || 'unknown' });
dispatchCurrentToolRatingPrompt(); dispatchCurrentToolRatingPrompt();
@@ -72,17 +86,35 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
)} )}
{/* Download button */} {/* Download button */}
<a {user ? (
href={result.download_url} <a
download={result.filename} href={result.download_url}
onClick={handleDownloadClick} download={result.filename}
className="btn-success w-full" onClick={handleDownloadClick}
target="_blank" className="btn-success w-full"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
<Download className="h-5 w-5" /> >
{t('common.download')} {result.filename} <Download className="h-5 w-5" />
</a> {t('common.download')} {result.filename}
</a>
) : (
<button
onClick={() => setShowGateModal(true)}
className="btn-primary w-full"
>
<Lock className="h-5 w-5" />
{t('downloadGate.downloadCta')}
</button>
)}
{showGateModal && (
<SignUpToDownloadModal
onClose={() => setShowGateModal(false)}
taskId={downloadTaskId}
toolSlug={currentToolSlug ?? undefined}
/>
)}
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">
<SharePanel <SharePanel

View File

@@ -0,0 +1,164 @@
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { UserPlus, LogIn, X, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore';
import { claimTask } from '@/services/api';
interface SignUpToDownloadModalProps {
onClose: () => 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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-slate-800">
{/* Close button */}
<button
onClick={onClose}
className="absolute end-3 top-3 rounded-full p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label={t('common.close')}
>
<X className="h-5 w-5" />
</button>
{/* Header */}
<div className="mb-4 text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<UserPlus className="h-7 w-7 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t('downloadGate.title')}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t('downloadGate.subtitle')}
</p>
</div>
{/* Benefits — compact */}
<ul className="mb-4 space-y-1.5 text-sm text-slate-600 dark:text-slate-300">
{[
t('downloadGate.benefit1'),
t('downloadGate.benefit2'),
t('downloadGate.benefit3'),
].map((b, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-0.5 text-emerald-500"></span>
{b}
</li>
))}
</ul>
{/* Inline auth form */}
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
required
value={email}
onChange={(e) => 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"
/>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => 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' && (
<input
type="password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => 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 && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full disabled:opacity-60"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : mode === 'register' ? (
<><UserPlus className="h-4 w-4" /> {t('account.submitRegister')}</>
) : (
<><LogIn className="h-4 w-4" /> {t('account.submitLogin')}</>
)}
</button>
</form>
{/* Toggle login / register */}
<button
type="button"
onClick={() => { setMode(mode === 'register' ? 'login' : 'register'); setError(null); }}
className="mt-3 w-full text-center text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{mode === 'register' ? t('downloadGate.signIn') : t('downloadGate.switchToRegister')}
</button>
</div>
</div>
);
}

View File

@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
aria-modal="true" aria-modal="true"
aria-labelledby="tool-selector-title" aria-labelledby="tool-selector-title"
> >
<div className="modal-content w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"> <div className="modal-content flex w-full max-w-lg max-h-[90vh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */} {/* Header */}
<div className="mb-5 flex items-start justify-between"> <div className="mb-5 flex items-start justify-between">
<div> <div>
@@ -123,26 +123,28 @@ export default function ToolSelectorModal({
</div> </div>
{/* Tools Grid */} {/* Tools Grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="max-h-[50vh] overflow-y-auto overscroll-contain">
{tools.map((tool) => { <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
const Icon = tool.icon; {tools.map((tool) => {
return ( const Icon = tool.icon;
<button return (
key={tool.key} <button
onClick={() => handleToolSelect(tool)} key={tool.key}
className="group flex flex-col items-center gap-2 rounded-xl p-4 ring-1 ring-slate-200 transition-all hover:ring-primary-300 hover:shadow-md dark:ring-slate-700 dark:hover:ring-primary-600" onClick={() => handleToolSelect(tool)}
> className="group flex flex-col items-center gap-2 rounded-xl p-4 ring-1 ring-slate-200 transition-all hover:ring-primary-300 hover:shadow-md dark:ring-slate-700 dark:hover:ring-primary-600"
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${tool.bgColor}`}
> >
<Icon className={`h-5 w-5 ${tool.iconColor}`} /> <div
</div> className={`flex h-10 w-10 items-center justify-center rounded-xl ${tool.bgColor}`}
<span className="text-center text-xs font-medium text-slate-700 group-hover:text-primary-600 dark:text-slate-300 dark:group-hover:text-primary-400"> >
{t(`tools.${tool.key}.shortDesc`)} <Icon className={`h-5 w-5 ${tool.iconColor}`} />
</span> </div>
</button> <span className="text-center text-xs font-medium text-slate-700 group-hover:text-primary-600 dark:text-slate-300 dark:group-hover:text-primary-400">
); {t(`tools.${tool.key}.shortDesc`)}
})} </span>
</button>
);
})}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { resolve, dirname } from 'path'; import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { ALL_ROUTES } from '@/config/routes'; import { ALL_ROUTES, TOOL_ROUTES } from '@/config/routes';
import { getAllSeoLandingPaths } from '@/config/seoPages'; import { getAllSeoLandingPaths } from '@/config/seoPages';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -12,7 +12,8 @@ const __dirname = dirname(__filename);
* SAFETY TEST — Route Integrity * SAFETY TEST — Route Integrity
* *
* Ensures that every route in the canonical registry (routes.ts) * Ensures that every route in the canonical registry (routes.ts)
* has a matching <Route path="..."> in App.tsx. * has a matching <Route path="..."> in App.tsx — either as a static
* path="..." attribute or via the TOOL_MANIFEST dynamic loop.
* *
* If this test fails it means either: * If this test fails it means either:
* 1. A route was removed from App.tsx (NEVER do this) * 1. A route was removed from App.tsx (NEVER do this)
@@ -25,7 +26,7 @@ describe('Route safety', () => {
); );
const seoLandingPaths = new Set(getAllSeoLandingPaths()); const seoLandingPaths = new Set(getAllSeoLandingPaths());
// Extract all path="..." values from <Route> elements // Extract all static path="..." values from <Route> elements
const routePathRegex = /path="([^"]+)"/g; const routePathRegex = /path="([^"]+)"/g;
const appPaths = new Set<string>(); const appPaths = new Set<string>();
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@@ -33,6 +34,11 @@ describe('Route safety', () => {
if (match[1] !== '*') appPaths.add(match[1]); 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', () => { it('App.tsx contains routes for every entry in the route registry', () => {
const hasDynamicSeoRoute = appPaths.has('/:slug'); const hasDynamicSeoRoute = appPaths.has('/:slug');
const missing = ALL_ROUTES.filter((route) => { const missing = ALL_ROUTES.filter((route) => {
@@ -40,6 +46,11 @@ describe('Route safety', () => {
return false; return false;
} }
// Tool routes covered by the manifest loop
if (hasManifestLoop && toolRouteSet.has(route)) {
return false;
}
if (hasDynamicSeoRoute && seoLandingPaths.has(route)) { if (hasDynamicSeoRoute && seoLandingPaths.has(route)) {
return false; return false;
} }

View File

@@ -4,9 +4,12 @@
* SAFETY RULE: Never remove a route from this list. * SAFETY RULE: Never remove a route from this list.
* New routes may only be appended. The route safety test * New routes may only be appended. The route safety test
* (routes.test.ts) will fail if any existing route is deleted. * (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 { getAllSeoLandingPaths } from '@/config/seoPages';
import { getManifestRoutePaths } from '@/config/toolManifest';
const STATIC_PAGE_ROUTES = [ const STATIC_PAGE_ROUTES = [
'/', '/',
@@ -35,68 +38,8 @@ export const PAGE_ROUTES = [
'/ar/:slug', '/ar/:slug',
] as const; ] as const;
// ─── Tool routes ───────────────────────────────────────────────── // ─── Tool routes (derived from manifest) ─────────────────────────
export const TOOL_ROUTES = [ export const TOOL_ROUTES = getManifestRoutePaths() as unknown as readonly string[];
// 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;
// ─── All routes combined ───────────────────────────────────────── // ─── All routes combined ─────────────────────────────────────────
export const ALL_ROUTES = [...PAGE_ROUTES, ...TOOL_ROUTES] as const; export const ALL_ROUTES = [...PAGE_ROUTES, ...TOOL_ROUTES] as const;

View File

@@ -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<string>();
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);
});
});

View File

@@ -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.<key>.title` / `tools.<key>.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}`);
}

View File

@@ -326,6 +326,18 @@
"وصول API" "وصول API"
], ],
"featureCompare": "مقارنة الميزات", "featureCompare": "مقارنة الميزات",
"features": {
"credits": "الرصيد لكل نافذة",
"apiAccess": "الوصول عبر API",
"apiRequests": "طلبات API",
"maxFileSize": "الحد الأقصى لحجم الملف",
"historyRetention": "حفظ السجل",
"allTools": "جميع الأدوات (44)",
"aiTools": "أدوات الذكاء الاصطناعي",
"priorityProcessing": "المعالجة ذات الأولوية",
"noAds": "بدون إعلانات",
"emailSupport": "دعم عبر البريد الإلكتروني"
},
"faqTitle": "الأسئلة الشائعة", "faqTitle": "الأسئلة الشائعة",
"faq": [ "faq": [
{ {
@@ -971,6 +983,8 @@
"webQuotaTitle": "مهام الويب هذا الشهر", "webQuotaTitle": "مهام الويب هذا الشهر",
"apiQuotaTitle": "مهام API هذا الشهر", "apiQuotaTitle": "مهام API هذا الشهر",
"quotaPeriod": "الفترة", "quotaPeriod": "الفترة",
"creditBalanceTitle": "رصيد الاستخدام",
"creditWindowResets": "يتجدد في",
"apiKeysTitle": "مفاتيح API", "apiKeysTitle": "مفاتيح API",
"apiKeysSubtitle": "أدر مفاتيح B2B API. كل مفتاح يمنحك وصولاً متزامناً بمستوى برو لجميع الأدوات.", "apiKeysSubtitle": "أدر مفاتيح B2B API. كل مفتاح يمنحك وصولاً متزامناً بمستوى برو لجميع الأدوات.",
"apiKeyNamePlaceholder": "اسم المفتاح (مثال: إنتاج)", "apiKeyNamePlaceholder": "اسم المفتاح (مثال: إنتاج)",
@@ -1017,6 +1031,17 @@
"downloadReady": "ملفك جاهز للتحميل.", "downloadReady": "ملفك جاهز للتحميل.",
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة." "linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة."
}, },
"downloadGate": {
"title": "سجّل لتحميل ملفك",
"subtitle": "ملفك جاهز. أنشئ حسابًا مجانيًا لتحميله.",
"benefit1": "حمّل ملفاتك المعالجة فورًا",
"benefit2": "50 رصيدًا مجانيًا كل 30 يومًا",
"benefit3": "الوصول إلى جميع الأدوات بدون قيود",
"createAccount": "إنشاء حساب مجاني",
"signIn": "لديك حساب بالفعل؟ سجّل الدخول",
"switchToRegister": "ليس لديك حساب؟ أنشئ واحدًا",
"downloadCta": "سجّل لتحميل الملف"
},
"seo": { "seo": {
"headings": { "headings": {
"whatItDoes": "ما تفعله هذه الأداة", "whatItDoes": "ما تفعله هذه الأداة",

View File

@@ -326,6 +326,18 @@
"API access" "API access"
], ],
"featureCompare": "Feature Comparison", "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", "faqTitle": "Frequently Asked Questions",
"faq": [ "faq": [
{ {
@@ -971,6 +983,8 @@
"webQuotaTitle": "Web Tasks This Month", "webQuotaTitle": "Web Tasks This Month",
"apiQuotaTitle": "API Tasks This Month", "apiQuotaTitle": "API Tasks This Month",
"quotaPeriod": "Period", "quotaPeriod": "Period",
"creditBalanceTitle": "Credit Balance",
"creditWindowResets": "Resets on",
"apiKeysTitle": "API Keys", "apiKeysTitle": "API Keys",
"apiKeysSubtitle": "Manage your B2B API keys. Each key gives Pro-level async access to all tools.", "apiKeysSubtitle": "Manage your B2B API keys. Each key gives Pro-level async access to all tools.",
"apiKeyNamePlaceholder": "Key name (e.g. Production)", "apiKeyNamePlaceholder": "Key name (e.g. Production)",
@@ -1017,6 +1031,17 @@
"downloadReady": "Your file is ready for download.", "downloadReady": "Your file is ready for download.",
"linkExpiry": "Download link expires in 30 minutes." "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": { "seo": {
"headings": { "headings": {
"whatItDoes": "What This Tool Does", "whatItDoes": "What This Tool Does",

View File

@@ -326,6 +326,18 @@
"Accès API" "Accès API"
], ],
"featureCompare": "Comparaison des fonctionnalités", "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", "faqTitle": "Questions fréquentes",
"faq": [ "faq": [
{ {
@@ -971,6 +983,8 @@
"webQuotaTitle": "Tâches web ce mois-ci", "webQuotaTitle": "Tâches web ce mois-ci",
"apiQuotaTitle": "Tâches API ce mois-ci", "apiQuotaTitle": "Tâches API ce mois-ci",
"quotaPeriod": "Période", "quotaPeriod": "Période",
"creditBalanceTitle": "Solde de crédits",
"creditWindowResets": "Se renouvelle le",
"apiKeysTitle": "Clés API", "apiKeysTitle": "Clés API",
"apiKeysSubtitle": "Gérez vos clés API B2B. Chaque clé donne un accès asynchrone Pro à tous les outils.", "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)", "apiKeyNamePlaceholder": "Nom de la clé (ex. Production)",
@@ -1017,6 +1031,17 @@
"downloadReady": "Votre fichier est prêt à être téléchargé.", "downloadReady": "Votre fichier est prêt à être téléchargé.",
"linkExpiry": "Le lien de téléchargement expire dans 30 minutes." "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": { "seo": {
"headings": { "headings": {
"whatItDoes": "Ce que fait cet outil", "whatItDoes": "Ce que fait cet outil",

View File

@@ -356,28 +356,30 @@ export default function AccountPage() {
</div> </div>
</section> </section>
{/* Usage / Quota Cards */} {/* Credit Balance Cards */}
{usage && ( {usage && usage.credits && (
<section className="grid gap-4 sm:grid-cols-2"> <section className="grid gap-4 sm:grid-cols-2">
<div className="card rounded-[1.5rem] p-5"> <div className="card rounded-[1.5rem] p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500"> <p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
{t('account.webQuotaTitle')} {t('account.creditBalanceTitle')}
</p> </p>
<p className="mt-1 text-2xl font-bold text-slate-900 dark:text-white"> <p className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
{usage.web_quota.used} {usage.credits.credits_remaining}
<span className="text-base font-normal text-slate-400"> / {usage.web_quota.limit ?? '∞'}</span> <span className="text-base font-normal text-slate-400"> / {usage.credits.credits_allocated}</span>
</p> </p>
{usage.web_quota.limit != null && ( <div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"> <div
<div className="h-full rounded-full bg-primary-500 transition-all"
className="h-full rounded-full bg-primary-500 transition-all" style={{ width: `${Math.min(100, (usage.credits.credits_used / usage.credits.credits_allocated) * 100)}%` }}
style={{ width: `${Math.min(100, (usage.web_quota.used / usage.web_quota.limit) * 100)}%` }} />
/> </div>
</div> {usage.credits.window_end && (
<p className="mt-2 text-xs text-slate-400">
{t('account.creditWindowResets')}: {new Date(usage.credits.window_end).toLocaleDateString()}
</p>
)} )}
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
</div> </div>
{usage.api_quota.limit != null && ( {usage.api_quota?.limit != null && (
<div className="card rounded-[1.5rem] p-5"> <div className="card rounded-[1.5rem] p-5">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500"> <p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
{t('account.apiQuotaTitle')} {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)}%` }} style={{ width: `${Math.min(100, (usage.api_quota.used / usage.api_quota.limit) * 100)}%` }}
/> />
</div> </div>
<p className="mt-2 text-xs text-slate-400">{t('account.quotaPeriod')}: {usage.period_month}</p>
</div> </div>
)} )}
</section> </section>

View File

@@ -33,11 +33,31 @@ import {
Table, Table,
Search, Search,
X, X,
Crop,
FileDown,
Wrench,
Presentation,
Barcode,
} from 'lucide-react'; } from 'lucide-react';
import ToolCard from '@/components/shared/ToolCard'; import ToolCard from '@/components/shared/ToolCard';
import HeroUploadZone from '@/components/shared/HeroUploadZone'; import HeroUploadZone from '@/components/shared/HeroUploadZone';
import AdSlot from '@/components/layout/AdSlot'; import AdSlot from '@/components/layout/AdSlot';
import SocialProofStrip from '@/components/shared/SocialProofStrip'; 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<string, React.ComponentType<{ className?: string }>> = {
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 <IconComponent className={`h-6 w-6 ${tool.iconColor}`} />;
}
interface ToolInfo { interface ToolInfo {
key: string; key: string;
@@ -46,44 +66,17 @@ interface ToolInfo {
bgColor: string; bgColor: string;
} }
const pdfTools: ToolInfo[] = [ function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] {
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: <PenLine className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' }, return tools.map((t) => ({
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' }, key: t.i18nKey,
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' }, path: `/tools/${t.slug}`,
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' }, icon: renderToolIcon(t),
{ key: 'mergePdf', path: '/tools/merge-pdf', icon: <Layers className="h-6 w-6 text-violet-600" />, bgColor: 'bg-violet-50' }, bgColor: t.bgColor,
{ key: 'splitPdf', path: '/tools/split-pdf', icon: <Scissors className="h-6 w-6 text-pink-600" />, bgColor: 'bg-pink-50' }, }));
{ key: 'rotatePdf', path: '/tools/rotate-pdf', icon: <RotateCw className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' }, }
{ key: 'pdfToImages', path: '/tools/pdf-to-images', icon: <Image className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'imagesToPdf', path: '/tools/images-to-pdf', icon: <FileImage className="h-6 w-6 text-lime-600" />, bgColor: 'bg-lime-50' },
{ key: 'watermarkPdf', path: '/tools/watermark-pdf', icon: <Droplets className="h-6 w-6 text-cyan-600" />, bgColor: 'bg-cyan-50' },
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: <Lock className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: <Unlock className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: <ListOrdered className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: <GitBranch className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'pdfToExcel', path: '/tools/pdf-to-excel', icon: <Sheet className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
{ key: 'removeWatermark', path: '/tools/remove-watermark-pdf', icon: <Droplets className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' },
{ key: 'reorderPdf', path: '/tools/reorder-pdf', icon: <ArrowUpDown className="h-6 w-6 text-violet-600" />, bgColor: 'bg-violet-50' },
{ key: 'extractPages', path: '/tools/extract-pages', icon: <FileOutput className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'chatPdf', path: '/tools/chat-pdf', icon: <MessageSquare className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'summarizePdf', path: '/tools/summarize-pdf', icon: <FileText className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
{ key: 'translatePdf', path: '/tools/translate-pdf', icon: <Languages className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
{ key: 'tableExtractor', path: '/tools/extract-tables', icon: <Table className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
];
const otherTools: ToolInfo[] = [ const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf'));
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' }, const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other'));
{ key: 'imageResize', path: '/tools/image-resize', icon: <Scaling className="h-6 w-6 text-teal-600" />, bgColor: 'bg-teal-50' },
{ key: 'compressImage', path: '/tools/compress-image', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
{ key: 'ocr', path: '/tools/ocr', icon: <ScanText className="h-6 w-6 text-amber-600" />, bgColor: 'bg-amber-50' },
{ key: 'removeBg', path: '/tools/remove-background', icon: <Eraser className="h-6 w-6 text-fuchsia-600" />, bgColor: 'bg-fuchsia-50' },
{ key: 'imageToSvg', path: '/tools/image-to-svg', icon: <ImageIcon className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
{ key: 'qrCode', path: '/tools/qr-code', icon: <QrCode className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
{ key: 'htmlToPdf', path: '/tools/html-to-pdf', icon: <Code className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'textCleaner', path: '/tools/text-cleaner', icon: <Eraser className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
];
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -18,7 +18,7 @@ interface PlanFeature {
} }
const FEATURES: 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: 'apiAccess', free: false, pro: true },
{ key: 'apiRequests', free: '—', pro: '1,000/month' }, { key: 'apiRequests', free: '—', pro: '1,000/month' },
{ key: 'maxFileSize', free: '50 MB', pro: '100 MB' }, { key: 'maxFileSize', free: '50 MB', pro: '100 MB' },

View File

@@ -479,6 +479,17 @@ export async function logoutUser(): Promise<void> {
await ensureCsrfToken(true); 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. * Return the current authenticated user, if any.
*/ */
@@ -965,9 +976,18 @@ export async function updateAdminUserRole(userId: number, role: string): Promise
// --- Account / Usage / API Keys --- // --- 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 { export interface UsageSummary {
plan: string; plan: string;
period_month: string; period_month?: string;
ads_enabled: boolean; ads_enabled: boolean;
history_limit: number; history_limit: number;
file_limits_mb: { file_limits_mb: {
@@ -977,8 +997,10 @@ export interface UsageSummary {
video: number; video: number;
homepageSmartUpload: number; homepageSmartUpload: number;
}; };
credits: CreditInfo;
tool_costs: Record<string, number>;
web_quota: { used: number; limit: number | null }; web_quota: { used: number; limit: number | null };
api_quota: { used: number; limit: number | null }; api_quota?: { used: number; limit: number | null };
} }
export interface ApiKey { export interface ApiKey {

View File

@@ -10,6 +10,8 @@ export interface ToolSeoData {
ratingValue?: number; ratingValue?: number;
ratingCount?: number; ratingCount?: number;
features?: string[]; features?: string[];
/** Optional HowTo steps for inline HowTo within the tool schema */
howToSteps?: string[];
} }
export interface LanguageAlternate { export interface LanguageAlternate {
@@ -81,12 +83,23 @@ export function generateToolSchema(tool: ToolSeoData): object {
operatingSystem: 'Any', operatingSystem: 'Any',
browserRequirements: 'Requires JavaScript. Works in modern browsers.', browserRequirements: 'Requires JavaScript. Works in modern browsers.',
isAccessibleForFree: true, isAccessibleForFree: true,
offers: { offers: [
'@type': 'Offer', {
price: '0', '@type': 'Offer',
priceCurrency: 'USD', name: 'Free',
availability: 'https://schema.org/InStock', 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, description: tool.description,
inLanguage: ['en', 'ar', 'fr'], inLanguage: ['en', 'ar', 'fr'],
provider: { provider: {
@@ -94,6 +107,12 @@ export function generateToolSchema(tool: ToolSeoData): object {
name: DEFAULT_SITE_NAME, name: DEFAULT_SITE_NAME,
url: getSiteOrigin(), url: getSiteOrigin(),
}, },
potentialAction: {
'@type': 'UseAction',
target: tool.url,
name: `Use ${tool.name}`,
},
screenshot: `${getSiteOrigin()}/social-preview.svg`,
}; };
if (tool.features && tool.features.length > 0) { if (tool.features && tool.features.length > 0) {