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:
43
.github/prompts/generate-backend-tests.prompt.md
vendored
Normal file
43
.github/prompts/generate-backend-tests.prompt.md
vendored
Normal 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}}
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
122
backend/app/services/credit_config.py
Normal file
122
backend/app/services/credit_config.py
Normal 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)
|
||||||
268
backend/app/services/credit_service.py
Normal file
268
backend/app/services/credit_service.py
Normal 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,
|
||||||
|
}
|
||||||
86
backend/app/services/guest_budget_service.py
Normal file
86
backend/app/services/guest_budget_service.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
151
backend/app/services/translation_guardrails.py
Normal file
151
backend/app/services/translation_guardrails.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
164
frontend/src/components/shared/SignUpToDownloadModal.tsx
Normal file
164
frontend/src/components/shared/SignUpToDownloadModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
114
frontend/src/config/toolManifest.test.ts
Normal file
114
frontend/src/config/toolManifest.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
601
frontend/src/config/toolManifest.ts
Normal file
601
frontend/src/config/toolManifest.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -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": "ما تفعله هذه الأداة",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user