chore: add @doist/todoist-ai
dependency to package.json اول دفعة من التطوير
This commit is contained in:
@@ -15,10 +15,17 @@ from app.services.policy_service import get_usage_summary_for_user
|
|||||||
from app.services.credit_config import (
|
from app.services.credit_config import (
|
||||||
get_all_tool_costs,
|
get_all_tool_costs,
|
||||||
get_credits_for_plan,
|
get_credits_for_plan,
|
||||||
|
get_dynamic_tools_info,
|
||||||
get_tool_credit_cost,
|
get_tool_credit_cost,
|
||||||
CREDIT_WINDOW_DAYS,
|
CREDIT_WINDOW_DAYS,
|
||||||
)
|
)
|
||||||
from app.services.credit_service import deduct_credits, get_credit_summary
|
from app.services.credit_service import deduct_credits, get_credit_summary
|
||||||
|
from app.services.quote_service import (
|
||||||
|
create_quote,
|
||||||
|
estimate_quote,
|
||||||
|
fulfill_quote,
|
||||||
|
QuoteError,
|
||||||
|
)
|
||||||
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,
|
||||||
@@ -57,6 +64,7 @@ def get_credit_info_route():
|
|||||||
"pro": {"credits": get_credits_for_plan("pro"), "window_days": CREDIT_WINDOW_DAYS},
|
"pro": {"credits": get_credits_for_plan("pro"), "window_days": CREDIT_WINDOW_DAYS},
|
||||||
},
|
},
|
||||||
"tool_costs": get_all_tool_costs(),
|
"tool_costs": get_all_tool_costs(),
|
||||||
|
"dynamic_tools": get_dynamic_tools_info(),
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +198,7 @@ def claim_task_route():
|
|||||||
|
|
||||||
Called after a guest signs up or logs in to record the previously
|
Called after a guest signs up or logs in to record the previously
|
||||||
processed task in their account and deduct credits.
|
processed task in their account and deduct credits.
|
||||||
|
Uses the quote engine, so welcome bonus is applied automatically.
|
||||||
"""
|
"""
|
||||||
user_id = get_current_user_id()
|
user_id = get_current_user_id()
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
@@ -216,15 +225,15 @@ def claim_task_route():
|
|||||||
return jsonify({"error": "User not found."}), 404
|
return jsonify({"error": "User not found."}), 404
|
||||||
|
|
||||||
plan = user.get("plan", "free")
|
plan = user.get("plan", "free")
|
||||||
cost = get_tool_credit_cost(tool)
|
|
||||||
|
|
||||||
# Deduct credits
|
# Use the quote engine (supports welcome bonus)
|
||||||
try:
|
try:
|
||||||
deduct_credits(user_id, plan, tool)
|
quote = create_quote(user_id, plan, tool)
|
||||||
except ValueError:
|
fulfill_quote(quote, user_id, plan)
|
||||||
|
except (QuoteError, ValueError) as exc:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": "Insufficient credits to claim this file.",
|
"error": str(exc),
|
||||||
"credits_required": cost,
|
"credits_required": get_tool_credit_cost(tool),
|
||||||
}), 429
|
}), 429
|
||||||
|
|
||||||
# Record usage event so the task appears in history
|
# Record usage event so the task appears in history
|
||||||
@@ -235,8 +244,38 @@ def claim_task_route():
|
|||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
event_type="accepted",
|
event_type="accepted",
|
||||||
api_key_id=None,
|
api_key_id=None,
|
||||||
cost_points=cost,
|
cost_points=quote.charged_credits,
|
||||||
|
quoted_credits=quote.quoted_credits,
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = get_credit_summary(user_id, plan)
|
summary = get_credit_summary(user_id, plan)
|
||||||
return jsonify({"claimed": True, "credits": summary}), 200
|
return jsonify({
|
||||||
|
"claimed": True,
|
||||||
|
"credits": summary,
|
||||||
|
"welcome_bonus_applied": quote.welcome_bonus_applied,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.route("/estimate", methods=["POST"])
|
||||||
|
@limiter.limit("120/hour")
|
||||||
|
def estimate_cost_route():
|
||||||
|
"""Return a non-binding credit cost estimate for a tool invocation.
|
||||||
|
|
||||||
|
Body: { "tool": "chat-pdf", "file_size_kb": 1024, "estimated_tokens": 5000 }
|
||||||
|
All fields except ``tool`` are optional.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id()
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
tool = str(data.get("tool", "")).strip()
|
||||||
|
if not tool:
|
||||||
|
return jsonify({"error": "tool is required."}), 400
|
||||||
|
|
||||||
|
file_size_kb = float(data.get("file_size_kb", 0))
|
||||||
|
estimated_tokens = int(data.get("estimated_tokens", 0))
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id) if user_id else None
|
||||||
|
plan = user.get("plan", "free") if user else "free"
|
||||||
|
|
||||||
|
result = estimate_quote(user_id, plan, tool, file_size_kb, estimated_tokens)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.services.account_service import (
|
|||||||
verify_and_consume_reset_token,
|
verify_and_consume_reset_token,
|
||||||
update_user_password,
|
update_user_password,
|
||||||
)
|
)
|
||||||
|
from app.services.credit_service import get_credit_summary
|
||||||
from app.services.email_service import send_password_reset_email
|
from app.services.email_service import send_password_reset_email
|
||||||
from app.utils.auth import (
|
from app.utils.auth import (
|
||||||
get_current_user_id,
|
get_current_user_id,
|
||||||
@@ -62,7 +63,13 @@ def register_route():
|
|||||||
return jsonify({"error": str(exc)}), 409
|
return jsonify({"error": str(exc)}), 409
|
||||||
|
|
||||||
login_user_session(user["id"])
|
login_user_session(user["id"])
|
||||||
return jsonify({"message": "Account created successfully.", "user": user}), 201
|
credits = get_credit_summary(user["id"], user.get("plan", "free"))
|
||||||
|
return jsonify({
|
||||||
|
"message": "Account created successfully.",
|
||||||
|
"user": user,
|
||||||
|
"credits": credits,
|
||||||
|
"is_new_account": True,
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["POST"])
|
@auth_bp.route("/login", methods=["POST"])
|
||||||
@@ -79,7 +86,12 @@ def login_route():
|
|||||||
return jsonify({"error": "Invalid email or password."}), 401
|
return jsonify({"error": "Invalid email or password."}), 401
|
||||||
|
|
||||||
login_user_session(user["id"])
|
login_user_session(user["id"])
|
||||||
return jsonify({"message": "Signed in successfully.", "user": user}), 200
|
credits = get_credit_summary(user["id"], user.get("plan", "free"))
|
||||||
|
return jsonify({
|
||||||
|
"message": "Signed in successfully.",
|
||||||
|
"user": user,
|
||||||
|
"credits": credits,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
@@ -103,7 +115,8 @@ def me_route():
|
|||||||
logout_user_session()
|
logout_user_session()
|
||||||
return jsonify({"authenticated": False, "user": None}), 200
|
return jsonify({"authenticated": False, "user": None}), 200
|
||||||
|
|
||||||
return jsonify({"authenticated": True, "user": user}), 200
|
credits = get_credit_summary(user_id, user.get("plan", "free"))
|
||||||
|
return jsonify({"authenticated": True, "user": user, "credits": credits}), 200
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/csrf", methods=["GET"])
|
@auth_bp.route("/csrf", methods=["GET"])
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""PDF compression routes."""
|
"""PDF compression routes."""
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
@@ -10,6 +12,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
validate_actor_file,
|
validate_actor_file,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import create_quote, QuoteError
|
||||||
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.compress_tasks import compress_pdf_task
|
from app.tasks.compress_tasks import compress_pdf_task
|
||||||
@@ -50,6 +53,12 @@ def compress_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "compress-pdf", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = compress_pdf_task.delay(
|
task = compress_pdf_task.delay(
|
||||||
input_path,
|
input_path,
|
||||||
task_id,
|
task_id,
|
||||||
@@ -57,9 +66,10 @@ def compress_pdf_route():
|
|||||||
quality,
|
quality,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "compress-pdf", task.id)
|
record_accepted_usage(actor, "compress-pdf", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Compression started. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Compression started. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}), 202
|
}), 202
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Image compression routes."""
|
"""Image compression routes."""
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
@@ -10,6 +12,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
validate_actor_file,
|
validate_actor_file,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import create_quote, QuoteError
|
||||||
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.compress_image_tasks import compress_image_task
|
from app.tasks.compress_image_tasks import compress_image_task
|
||||||
@@ -57,6 +60,12 @@ def compress_image_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "compress-image", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = compress_image_task.delay(
|
task = compress_image_task.delay(
|
||||||
input_path,
|
input_path,
|
||||||
task_id,
|
task_id,
|
||||||
@@ -64,9 +73,10 @@ def compress_image_route():
|
|||||||
quality,
|
quality,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "compress-image", task.id)
|
record_accepted_usage(actor, "compress-image", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Image compression started. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Image compression started. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}), 202
|
}), 202
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
FREE_PLAN,
|
FREE_PLAN,
|
||||||
)
|
)
|
||||||
|
from app.services.credit_config import get_dynamic_tools_info
|
||||||
|
|
||||||
config_bp = Blueprint("config", __name__)
|
config_bp = Blueprint("config", __name__)
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ def get_config():
|
|||||||
payload: dict = {
|
payload: dict = {
|
||||||
"file_limits_mb": file_limits_mb,
|
"file_limits_mb": file_limits_mb,
|
||||||
"max_upload_mb": max(file_limits_mb.values()),
|
"max_upload_mb": max(file_limits_mb.values()),
|
||||||
|
"dynamic_tools": get_dynamic_tools_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if actor.user_id is not None:
|
if actor.user_id is not None:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""OCR routes — extract text from images and PDFs."""
|
"""OCR routes — extract text from images and PDFs."""
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
@@ -10,6 +12,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
validate_actor_file,
|
validate_actor_file,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import create_quote, QuoteError
|
||||||
from app.services.ocr_service import SUPPORTED_LANGUAGES
|
from app.services.ocr_service import SUPPORTED_LANGUAGES
|
||||||
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
|
||||||
@@ -66,15 +69,22 @@ def ocr_image_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "ocr-image", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = ocr_image_task.delay(
|
task = ocr_image_task.delay(
|
||||||
input_path, task_id, original_filename, lang,
|
input_path, task_id, original_filename, lang,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "ocr-image", task.id)
|
record_accepted_usage(actor, "ocr-image", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "OCR started. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "OCR started. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}), 202
|
}), 202
|
||||||
|
|
||||||
|
|
||||||
@@ -116,15 +126,22 @@ def ocr_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "ocr-pdf", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = ocr_pdf_task.delay(
|
task = ocr_pdf_task.delay(
|
||||||
input_path, task_id, original_filename, lang,
|
input_path, task_id, original_filename, lang,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "ocr-pdf", task.id)
|
record_accepted_usage(actor, "ocr-pdf", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "OCR started. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "OCR started. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}), 202
|
}), 202
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""PDF AI tool routes — Chat, Summarize, Translate, Table Extract."""
|
"""PDF AI tool routes — Chat, Summarize, Translate, Table Extract."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
@@ -11,6 +13,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
validate_actor_file,
|
validate_actor_file,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import create_quote, QuoteError
|
||||||
from app.services.translation_guardrails import (
|
from app.services.translation_guardrails import (
|
||||||
check_page_admission,
|
check_page_admission,
|
||||||
TranslationAdmissionError,
|
TranslationAdmissionError,
|
||||||
@@ -66,6 +69,13 @@ def chat_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "chat-pdf", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = chat_with_pdf_task.delay(
|
task = chat_with_pdf_task.delay(
|
||||||
input_path,
|
input_path,
|
||||||
task_id,
|
task_id,
|
||||||
@@ -73,12 +83,13 @@ def chat_pdf_route():
|
|||||||
question,
|
question,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "chat-pdf", task.id)
|
record_accepted_usage(actor, "chat-pdf", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Processing your question. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Processing your question. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}
|
}
|
||||||
), 202
|
), 202
|
||||||
|
|
||||||
@@ -122,6 +133,13 @@ def summarize_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "summarize-pdf", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = summarize_pdf_task.delay(
|
task = summarize_pdf_task.delay(
|
||||||
input_path,
|
input_path,
|
||||||
task_id,
|
task_id,
|
||||||
@@ -129,12 +147,13 @@ def summarize_pdf_route():
|
|||||||
length,
|
length,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "summarize-pdf", task.id)
|
record_accepted_usage(actor, "summarize-pdf", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Summarizing document. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Summarizing document. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}
|
}
|
||||||
), 202
|
), 202
|
||||||
|
|
||||||
@@ -185,6 +204,13 @@ def translate_pdf_route():
|
|||||||
except TranslationAdmissionError as e:
|
except TranslationAdmissionError as e:
|
||||||
return jsonify({"error": e.message}), e.status_code
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "translate-pdf", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError 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,
|
||||||
@@ -193,12 +219,13 @@ def translate_pdf_route():
|
|||||||
source_language,
|
source_language,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "translate-pdf", task.id)
|
record_accepted_usage(actor, "translate-pdf", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Translating document. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Translating document. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}
|
}
|
||||||
), 202
|
), 202
|
||||||
|
|
||||||
@@ -237,17 +264,25 @@ def extract_tables_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "extract-tables", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = extract_tables_task.delay(
|
task = extract_tables_task.delay(
|
||||||
input_path,
|
input_path,
|
||||||
task_id,
|
task_id,
|
||||||
original_filename,
|
original_filename,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "extract-tables", task.id)
|
record_accepted_usage(actor, "extract-tables", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Extracting tables. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Extracting tables. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}
|
}
|
||||||
), 202
|
), 202
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Background removal route."""
|
"""Background removal route."""
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
@@ -10,6 +12,7 @@ from app.services.policy_service import (
|
|||||||
resolve_web_actor,
|
resolve_web_actor,
|
||||||
validate_actor_file,
|
validate_actor_file,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import create_quote, QuoteError
|
||||||
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.removebg_tasks import remove_bg_task
|
from app.tasks.removebg_tasks import remove_bg_task
|
||||||
@@ -52,13 +55,20 @@ def remove_bg_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)
|
||||||
|
|
||||||
|
file_size_kb = os.path.getsize(input_path) / 1024
|
||||||
|
try:
|
||||||
|
quote = create_quote(actor.user_id, actor.plan, "remove-bg", file_size_kb=file_size_kb)
|
||||||
|
except QuoteError as e:
|
||||||
|
return jsonify({"error": e.message}), e.status_code
|
||||||
|
|
||||||
task = remove_bg_task.delay(
|
task = remove_bg_task.delay(
|
||||||
input_path, task_id, original_filename,
|
input_path, task_id, original_filename,
|
||||||
**build_task_tracking_kwargs(actor),
|
**build_task_tracking_kwargs(actor),
|
||||||
)
|
)
|
||||||
record_accepted_usage(actor, "remove-bg", task.id)
|
record_accepted_usage(actor, "remove-bg", task.id, quote=quote)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"message": "Background removal started. Poll /api/tasks/{task_id}/status for progress.",
|
"message": "Background removal started. Poll /api/tasks/{task_id}/status for progress.",
|
||||||
|
"quote": quote.to_dict(),
|
||||||
}), 202
|
}), 202
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ def _serialize_user(row: dict | None) -> dict | None:
|
|||||||
"role": _resolve_row_role(row),
|
"role": _resolve_row_role(row),
|
||||||
"is_allowlisted_admin": is_allowlisted_admin_email(row.get("email")),
|
"is_allowlisted_admin": is_allowlisted_admin_email(row.get("email")),
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
|
"welcome_bonus_available": int(row.get("welcome_bonus_used", 0)) == 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -252,6 +253,18 @@ def _init_postgres_tables(conn):
|
|||||||
"ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1"
|
"ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add quoted_credits column to usage_events if missing
|
||||||
|
if not _column_exists(conn, "usage_events", "quoted_credits"):
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add welcome_bonus_used flag to users if missing
|
||||||
|
if not _column_exists(conn, "users", "welcome_bonus_used"):
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _init_sqlite_tables(conn):
|
def _init_sqlite_tables(conn):
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
@@ -366,6 +379,10 @@ def _init_sqlite_tables(conn):
|
|||||||
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"):
|
if not _column_exists(conn, "usage_events", "cost_points"):
|
||||||
conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1")
|
||||||
|
if not _column_exists(conn, "usage_events", "quoted_credits"):
|
||||||
|
conn.execute("ALTER TABLE usage_events ADD COLUMN quoted_credits INTEGER")
|
||||||
|
if not _column_exists(conn, "users", "welcome_bonus_used"):
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN welcome_bonus_used INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
def create_user(email: str, password: str) -> dict:
|
def create_user(email: str, password: str) -> dict:
|
||||||
@@ -398,9 +415,9 @@ def create_user(email: str, password: str) -> dict:
|
|||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
|
|
||||||
row_sql = (
|
row_sql = (
|
||||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||||
if is_postgres()
|
if is_postgres()
|
||||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
cursor2 = execute_query(conn, row_sql, (user_id,))
|
cursor2 = execute_query(conn, row_sql, (user_id,))
|
||||||
row = cursor2.fetchone()
|
row = cursor2.fetchone()
|
||||||
@@ -435,9 +452,9 @@ def authenticate_user(email: str, password: str) -> dict | None:
|
|||||||
def get_user_by_id(user_id: int) -> dict | None:
|
def get_user_by_id(user_id: int) -> dict | None:
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
sql = (
|
sql = (
|
||||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||||
if is_postgres()
|
if is_postgres()
|
||||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
cursor = execute_query(conn, sql, (user_id,))
|
cursor = execute_query(conn, sql, (user_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -485,9 +502,9 @@ def set_user_role(user_id: int, role: str) -> dict | None:
|
|||||||
execute_query(conn, sql, (normalized_role, _utc_now(), user_id))
|
execute_query(conn, sql, (normalized_role, _utc_now(), user_id))
|
||||||
|
|
||||||
sql2 = (
|
sql2 = (
|
||||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||||
if is_postgres()
|
if is_postgres()
|
||||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
cursor = execute_query(conn, sql2, (user_id,))
|
cursor = execute_query(conn, sql2, (user_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -518,9 +535,9 @@ def update_user_plan(user_id: int, plan: str) -> dict | None:
|
|||||||
execute_query(conn, sql, (normalized_plan, _utc_now(), user_id))
|
execute_query(conn, sql, (normalized_plan, _utc_now(), user_id))
|
||||||
|
|
||||||
sql2 = (
|
sql2 = (
|
||||||
"SELECT id, email, plan, role, created_at FROM users WHERE id = %s"
|
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = %s"
|
||||||
if is_postgres()
|
if is_postgres()
|
||||||
else "SELECT id, email, plan, role, created_at FROM users WHERE id = ?"
|
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE id = ?"
|
||||||
)
|
)
|
||||||
cursor = execute_query(conn, sql2, (user_id,))
|
cursor = execute_query(conn, sql2, (user_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -884,6 +901,7 @@ def record_usage_event(
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
api_key_id: int | None = None,
|
api_key_id: int | None = None,
|
||||||
cost_points: int = 1,
|
cost_points: int = 1,
|
||||||
|
quoted_credits: int | None = None,
|
||||||
):
|
):
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return
|
return
|
||||||
@@ -893,17 +911,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, cost_points
|
event_type, created_at, period_month, cost_points, quoted_credits
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %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, cost_points
|
event_type, created_at, period_month, cost_points, quoted_credits
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
execute_query(
|
execute_query(
|
||||||
@@ -919,6 +937,7 @@ def record_usage_event(
|
|||||||
_utc_now(),
|
_utc_now(),
|
||||||
get_current_period_month(),
|
get_current_period_month(),
|
||||||
cost_points,
|
cost_points,
|
||||||
|
quoted_credits,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -982,9 +1001,9 @@ def get_user_by_email(email: str) -> dict | None:
|
|||||||
email = _normalize_email(email)
|
email = _normalize_email(email)
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
sql = (
|
sql = (
|
||||||
"SELECT id, email, plan, role, created_at FROM users WHERE email = %s"
|
"SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = %s"
|
||||||
if is_postgres()
|
if is_postgres()
|
||||||
else "SELECT id, email, plan, role, created_at FROM users WHERE email = ?"
|
else "SELECT id, email, plan, role, created_at, welcome_bonus_used FROM users WHERE email = ?"
|
||||||
)
|
)
|
||||||
cursor = execute_query(conn, sql, (email,))
|
cursor = execute_query(conn, sql, (email,))
|
||||||
row = row_to_dict(cursor.fetchone())
|
row = row_to_dict(cursor.fetchone())
|
||||||
@@ -1087,3 +1106,32 @@ def log_file_event(
|
|||||||
else "INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)"
|
else "INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)"
|
||||||
)
|
)
|
||||||
execute_query(conn, sql, (event_type, file_path, detail, _utc_now()))
|
execute_query(conn, sql, (event_type, file_path, detail, _utc_now()))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Welcome-bonus helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
def is_welcome_bonus_available(user_id: int) -> bool:
|
||||||
|
"""Return True if the user has never used their welcome bonus."""
|
||||||
|
with db_connection() as conn:
|
||||||
|
sql = (
|
||||||
|
"SELECT welcome_bonus_used FROM users WHERE id = %s"
|
||||||
|
if is_postgres()
|
||||||
|
else "SELECT welcome_bonus_used FROM users WHERE id = ?"
|
||||||
|
)
|
||||||
|
cursor = execute_query(conn, sql, (user_id,))
|
||||||
|
row = row_to_dict(cursor.fetchone())
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
return int(row.get("welcome_bonus_used", 0)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def consume_welcome_bonus(user_id: int) -> bool:
|
||||||
|
"""Mark the welcome bonus as used. Returns True if it was actually consumed."""
|
||||||
|
with db_connection() as conn:
|
||||||
|
sql = (
|
||||||
|
"UPDATE users SET welcome_bonus_used = 1, updated_at = %s WHERE id = %s AND welcome_bonus_used = 0"
|
||||||
|
if is_postgres()
|
||||||
|
else "UPDATE users SET welcome_bonus_used = 1, updated_at = ? WHERE id = ? AND welcome_bonus_used = 0"
|
||||||
|
)
|
||||||
|
cursor = execute_query(conn, sql, (_utc_now(), user_id))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ Every tool has a credit cost. Lighter tools cost 1 credit, heavier
|
|||||||
server-side conversions cost 2, CPU/ML-intensive tools cost 3,
|
server-side conversions cost 2, CPU/ML-intensive tools cost 3,
|
||||||
and AI-powered tools cost 5+.
|
and AI-powered tools cost 5+.
|
||||||
|
|
||||||
|
Heavy and AI tools use dynamic pricing based on file/request size.
|
||||||
|
Light and medium tools keep a fixed cost per invocation.
|
||||||
|
|
||||||
This module is the single source of truth for all credit-related
|
This module is the single source of truth for all credit-related
|
||||||
constants consumed by policy_service, credit_service, and the
|
constants consumed by policy_service, credit_service, quote_service,
|
||||||
frontend config endpoint.
|
and the frontend config endpoint.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# ── Credit allocations per rolling 30-day window ────────────────
|
# ── Credit allocations per rolling 30-day window ────────────────
|
||||||
FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50"))
|
FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50"))
|
||||||
@@ -29,6 +36,98 @@ TIER_MEDIUM = 2 # Server-side conversion (LibreOffice, Ghostscript, etc.)
|
|||||||
TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression)
|
TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression)
|
||||||
TIER_AI = 5 # AI-powered tools (LLM API calls)
|
TIER_AI = 5 # AI-powered tools (LLM API calls)
|
||||||
|
|
||||||
|
# ── Dynamic pricing model ──────────────────────────────────────
|
||||||
|
# Tools marked as DYNAMIC_PRICING_TIERS use size-based cost instead of
|
||||||
|
# fixed tier cost. The formula is:
|
||||||
|
# cost = base + ceil(file_size_kb / step_kb) * per_step
|
||||||
|
# capped at max_credits. AI tools also add an estimated-tokens surcharge.
|
||||||
|
|
||||||
|
DYNAMIC_PRICING_TIERS: set[str] = {"heavy", "ai"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DynamicPricingRule:
|
||||||
|
"""Size-based pricing rule for one tool family."""
|
||||||
|
|
||||||
|
base: int # minimum credits charged
|
||||||
|
step_kb: int # every `step_kb` KB adds `per_step` credits
|
||||||
|
per_step: int # credits added per step
|
||||||
|
max_credits: int # hard cap on credits per invocation
|
||||||
|
|
||||||
|
# AI-specific: extra credits per estimated 1 000 input tokens
|
||||||
|
token_step: int = 0 # 0 means no token surcharge
|
||||||
|
per_token_step: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# Default rules per tier — individual tools can override via
|
||||||
|
# TOOL_DYNAMIC_OVERRIDES below.
|
||||||
|
HEAVY_DEFAULT_RULE = DynamicPricingRule(
|
||||||
|
base=3, step_kb=500, per_step=1, max_credits=10,
|
||||||
|
)
|
||||||
|
AI_DEFAULT_RULE = DynamicPricingRule(
|
||||||
|
base=5, step_kb=200, per_step=1, max_credits=20,
|
||||||
|
token_step=1000, per_token_step=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-tool overrides (tool slug → rule).
|
||||||
|
TOOL_DYNAMIC_OVERRIDES: dict[str, DynamicPricingRule] = {
|
||||||
|
# Translation is heavier than chat/summarize
|
||||||
|
"translate-pdf": DynamicPricingRule(
|
||||||
|
base=6, step_kb=150, per_step=1, max_credits=25,
|
||||||
|
token_step=1000, per_token_step=1,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_label(cost: int) -> str:
|
||||||
|
"""Return the tier family name for a fixed cost value."""
|
||||||
|
if cost >= TIER_AI:
|
||||||
|
return "ai"
|
||||||
|
if cost >= TIER_HEAVY:
|
||||||
|
return "heavy"
|
||||||
|
if cost >= TIER_MEDIUM:
|
||||||
|
return "medium"
|
||||||
|
return "light"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dynamic_rule(tool: str, tier: str) -> DynamicPricingRule | None:
|
||||||
|
"""Return the dynamic pricing rule for *tool*, or None if fixed."""
|
||||||
|
if tier not in DYNAMIC_PRICING_TIERS:
|
||||||
|
return None
|
||||||
|
if tool in TOOL_DYNAMIC_OVERRIDES:
|
||||||
|
return TOOL_DYNAMIC_OVERRIDES[tool]
|
||||||
|
return AI_DEFAULT_RULE if tier == "ai" else HEAVY_DEFAULT_RULE
|
||||||
|
|
||||||
|
|
||||||
|
def is_dynamic_tool(tool: str) -> bool:
|
||||||
|
"""Return True if *tool* uses size-based dynamic pricing."""
|
||||||
|
base = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||||
|
return _tier_label(base) in DYNAMIC_PRICING_TIERS
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_dynamic_cost(
|
||||||
|
tool: str,
|
||||||
|
file_size_kb: float = 0,
|
||||||
|
estimated_tokens: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""Calculate the credit cost for a dynamic tool given size metrics.
|
||||||
|
|
||||||
|
For fixed-price tools this returns the static tier cost unchanged.
|
||||||
|
"""
|
||||||
|
base_cost = TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||||
|
tier = _tier_label(base_cost)
|
||||||
|
rule = _get_dynamic_rule(tool, tier)
|
||||||
|
if rule is None:
|
||||||
|
return base_cost
|
||||||
|
|
||||||
|
size_surcharge = math.ceil(max(0, file_size_kb) / rule.step_kb) * rule.per_step
|
||||||
|
token_surcharge = 0
|
||||||
|
if rule.token_step and estimated_tokens > 0:
|
||||||
|
token_surcharge = math.ceil(estimated_tokens / rule.token_step) * rule.per_token_step
|
||||||
|
|
||||||
|
total = rule.base + size_surcharge + token_surcharge
|
||||||
|
return min(total, rule.max_credits)
|
||||||
|
|
||||||
# ── Per-tool credit costs ───────────────────────────────────────
|
# ── Per-tool credit costs ───────────────────────────────────────
|
||||||
# Keys match the `tool` parameter passed to record_usage_event / routes.
|
# Keys match the `tool` parameter passed to record_usage_event / routes.
|
||||||
TOOL_CREDIT_COSTS: dict[str, int] = {
|
TOOL_CREDIT_COSTS: dict[str, int] = {
|
||||||
@@ -108,7 +207,11 @@ DEFAULT_CREDIT_COST = TIER_LIGHT
|
|||||||
|
|
||||||
|
|
||||||
def get_tool_credit_cost(tool: str) -> int:
|
def get_tool_credit_cost(tool: str) -> int:
|
||||||
"""Return the credit cost for a given tool slug."""
|
"""Return the *fixed* credit cost for a given tool slug.
|
||||||
|
|
||||||
|
For dynamic tools this is the base/minimum cost.
|
||||||
|
Use :func:`calculate_dynamic_cost` when file size is known.
|
||||||
|
"""
|
||||||
return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,3 +223,29 @@ def get_credits_for_plan(plan: str) -> int:
|
|||||||
def get_all_tool_costs() -> dict[str, int]:
|
def get_all_tool_costs() -> dict[str, int]:
|
||||||
"""Return the full cost registry — used by the config API endpoint."""
|
"""Return the full cost registry — used by the config API endpoint."""
|
||||||
return dict(TOOL_CREDIT_COSTS)
|
return dict(TOOL_CREDIT_COSTS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_dynamic_tools_info() -> dict[str, dict]:
|
||||||
|
"""Return metadata about tools that use dynamic pricing.
|
||||||
|
|
||||||
|
Used by the config/credit-info endpoint so the frontend can display
|
||||||
|
"price varies by file size" for these tools.
|
||||||
|
"""
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
seen: set[str] = set()
|
||||||
|
for slug, cost in TOOL_CREDIT_COSTS.items():
|
||||||
|
tier = _tier_label(cost)
|
||||||
|
rule = _get_dynamic_rule(slug, tier)
|
||||||
|
if rule is None:
|
||||||
|
continue
|
||||||
|
if slug in seen:
|
||||||
|
continue
|
||||||
|
seen.add(slug)
|
||||||
|
result[slug] = {
|
||||||
|
"base": rule.base,
|
||||||
|
"step_kb": rule.step_kb,
|
||||||
|
"per_step": rule.per_step,
|
||||||
|
"max_credits": rule.max_credits,
|
||||||
|
"has_token_surcharge": rule.token_step > 0,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|||||||
@@ -219,19 +219,26 @@ def deduct_credits(user_id: int, plan: str, tool: str) -> int:
|
|||||||
Raises ValueError if insufficient credits.
|
Raises ValueError if insufficient credits.
|
||||||
"""
|
"""
|
||||||
cost = get_tool_credit_cost(tool)
|
cost = get_tool_credit_cost(tool)
|
||||||
|
return deduct_credits_quoted(user_id, plan, cost)
|
||||||
|
|
||||||
|
|
||||||
|
def deduct_credits_quoted(user_id: int, plan: str, cost: int) -> int:
|
||||||
|
"""Deduct an explicit credit amount from the user's window.
|
||||||
|
|
||||||
|
Used by the quote engine to deduct a pre-calculated (possibly dynamic)
|
||||||
|
cost rather than looking up the fixed tier cost.
|
||||||
|
Raises ValueError if insufficient credits.
|
||||||
|
"""
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
# Ensure window is current
|
|
||||||
window = _get_window(conn, user_id)
|
window = _get_window(conn, user_id)
|
||||||
if window is None or _is_window_expired(window.get("window_end_at", "")):
|
if window is None or _is_window_expired(window.get("window_end_at", "")):
|
||||||
# get_or_create handles reset
|
|
||||||
pass
|
pass
|
||||||
window = get_or_create_credit_window(user_id, plan)
|
window = get_or_create_credit_window(user_id, plan)
|
||||||
|
|
||||||
balance = window["credits_allocated"] - window["credits_used"]
|
balance = window["credits_allocated"] - window["credits_used"]
|
||||||
if balance < cost:
|
if balance < cost:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Insufficient credits: {balance} remaining, {cost} required for {tool}."
|
f"Insufficient credits: {balance} remaining, {cost} required."
|
||||||
)
|
)
|
||||||
|
|
||||||
sql = (
|
sql = (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.services.guest_budget_service import (
|
|||||||
assert_guest_budget_available,
|
assert_guest_budget_available,
|
||||||
record_guest_usage,
|
record_guest_usage,
|
||||||
)
|
)
|
||||||
|
from app.services.quote_service import CreditQuote, fulfill_quote
|
||||||
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
|
||||||
@@ -223,29 +224,48 @@ def assert_quota_available(actor: ActorContext, tool: str | None = None):
|
|||||||
raise PolicyError("Your monthly API quota has been reached.", 429)
|
raise PolicyError("Your monthly API quota has been reached.", 429)
|
||||||
|
|
||||||
|
|
||||||
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
|
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str, quote: CreditQuote | None = None):
|
||||||
"""Record one accepted usage event and deduct credits after task dispatch."""
|
"""Record one accepted usage event and deduct credits after task dispatch.
|
||||||
|
|
||||||
|
When *quote* is provided the quote engine handles deduction (supports
|
||||||
|
dynamic pricing and welcome bonus). Without a quote, falls back to the
|
||||||
|
legacy fixed-cost deduction path.
|
||||||
|
"""
|
||||||
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)
|
charged = 0
|
||||||
|
|
||||||
# Deduct credits from the rolling window (registered users only)
|
# Deduct credits from the rolling window (registered users only)
|
||||||
if actor.user_id is not None and actor.source == "web":
|
if actor.user_id is not None and actor.source == "web":
|
||||||
|
if quote is not None:
|
||||||
|
try:
|
||||||
|
charged = fulfill_quote(quote, actor.user_id, actor.plan)
|
||||||
|
except ValueError:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Quote fulfillment failed for user %d tool %s",
|
||||||
|
actor.user_id,
|
||||||
|
tool,
|
||||||
|
)
|
||||||
|
charged = quote.charged_credits
|
||||||
|
else:
|
||||||
|
cost = get_tool_credit_cost(tool)
|
||||||
try:
|
try:
|
||||||
deduct_credits(actor.user_id, actor.plan, tool)
|
deduct_credits(actor.user_id, actor.plan, tool)
|
||||||
|
charged = cost
|
||||||
except ValueError:
|
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
|
import logging
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
|
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
|
||||||
actor.user_id,
|
actor.user_id,
|
||||||
tool,
|
tool,
|
||||||
)
|
)
|
||||||
|
charged = cost
|
||||||
elif actor.user_id is None and actor.source == "web":
|
elif actor.user_id is None and actor.source == "web":
|
||||||
# Record guest demo usage
|
# Record guest demo usage
|
||||||
record_guest_usage()
|
record_guest_usage()
|
||||||
|
charged = get_tool_credit_cost(tool)
|
||||||
|
|
||||||
record_usage_event(
|
record_usage_event(
|
||||||
user_id=actor.user_id,
|
user_id=actor.user_id,
|
||||||
@@ -254,7 +274,8 @@ 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,
|
cost_points=charged,
|
||||||
|
quoted_credits=quote.quoted_credits if quote else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
211
backend/app/services/quote_service.py
Normal file
211
backend/app/services/quote_service.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""Central credit-quote engine — calculate and lock a price before dispatch.
|
||||||
|
|
||||||
|
The quote engine sits between route handlers and the credit/policy layer.
|
||||||
|
It produces a ``CreditQuote`` that is:
|
||||||
|
1. Shown to the user in the 202 response.
|
||||||
|
2. Used to deduct credits (instead of the old fixed-cost path).
|
||||||
|
3. Stored in usage_events.quoted_credits for audit.
|
||||||
|
|
||||||
|
Light/medium tools still get a fixed quote (== tier cost, no size factor).
|
||||||
|
Heavy and AI tools get a dynamic quote based on file size and estimated
|
||||||
|
tokens (for AI tools).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.services.account_service import (
|
||||||
|
consume_welcome_bonus,
|
||||||
|
is_welcome_bonus_available,
|
||||||
|
)
|
||||||
|
from app.services.credit_config import (
|
||||||
|
calculate_dynamic_cost,
|
||||||
|
get_tool_credit_cost,
|
||||||
|
is_dynamic_tool,
|
||||||
|
)
|
||||||
|
from app.services.credit_service import (
|
||||||
|
deduct_credits_quoted,
|
||||||
|
get_rolling_balance,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CreditQuote:
|
||||||
|
"""An immutable, pre-dispatch credit quote."""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
base_cost: int # fixed tier cost
|
||||||
|
quoted_credits: int # final cost after dynamic calc
|
||||||
|
charged_credits: int # actual credits that will be deducted (0 if welcome bonus)
|
||||||
|
welcome_bonus_applied: bool
|
||||||
|
is_dynamic: bool
|
||||||
|
file_size_kb: float
|
||||||
|
estimated_tokens: int
|
||||||
|
balance_before: int
|
||||||
|
balance_after: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize for JSON response payloads."""
|
||||||
|
return {
|
||||||
|
"tool": self.tool,
|
||||||
|
"quoted_credits": self.quoted_credits,
|
||||||
|
"charged_credits": self.charged_credits,
|
||||||
|
"welcome_bonus_applied": self.welcome_bonus_applied,
|
||||||
|
"is_dynamic": self.is_dynamic,
|
||||||
|
"file_size_kb": round(self.file_size_kb, 1),
|
||||||
|
"balance_before": self.balance_before,
|
||||||
|
"balance_after": self.balance_after,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteError(Exception):
|
||||||
|
"""Quote could not be fulfilled (insufficient credits, etc.)."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 402):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_quote(
|
||||||
|
user_id: int | None,
|
||||||
|
plan: str,
|
||||||
|
tool: str,
|
||||||
|
file_size_kb: float = 0,
|
||||||
|
estimated_tokens: int = 0,
|
||||||
|
) -> CreditQuote:
|
||||||
|
"""Build a quote for one tool invocation.
|
||||||
|
|
||||||
|
For anonymous users (user_id is None), returns a zero-cost quote
|
||||||
|
because guest budget is handled separately by guest_budget_service.
|
||||||
|
"""
|
||||||
|
if user_id is None:
|
||||||
|
base = get_tool_credit_cost(tool)
|
||||||
|
return CreditQuote(
|
||||||
|
tool=tool,
|
||||||
|
base_cost=base,
|
||||||
|
quoted_credits=base,
|
||||||
|
charged_credits=0,
|
||||||
|
welcome_bonus_applied=False,
|
||||||
|
is_dynamic=False,
|
||||||
|
file_size_kb=file_size_kb,
|
||||||
|
estimated_tokens=estimated_tokens,
|
||||||
|
balance_before=0,
|
||||||
|
balance_after=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dynamic = is_dynamic_tool(tool)
|
||||||
|
base = get_tool_credit_cost(tool)
|
||||||
|
|
||||||
|
if dynamic:
|
||||||
|
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
|
||||||
|
else:
|
||||||
|
quoted = base
|
||||||
|
|
||||||
|
balance = get_rolling_balance(user_id, plan)
|
||||||
|
|
||||||
|
# Welcome bonus: first transaction is free
|
||||||
|
bonus_available = is_welcome_bonus_available(user_id)
|
||||||
|
if bonus_available:
|
||||||
|
charged = 0
|
||||||
|
else:
|
||||||
|
charged = quoted
|
||||||
|
|
||||||
|
if charged > balance:
|
||||||
|
raise QuoteError(
|
||||||
|
f"Insufficient credits: {balance} remaining, {charged} required for {tool}.",
|
||||||
|
402,
|
||||||
|
)
|
||||||
|
|
||||||
|
balance_after = balance - charged
|
||||||
|
|
||||||
|
return CreditQuote(
|
||||||
|
tool=tool,
|
||||||
|
base_cost=base,
|
||||||
|
quoted_credits=quoted,
|
||||||
|
charged_credits=charged,
|
||||||
|
welcome_bonus_applied=bonus_available,
|
||||||
|
is_dynamic=dynamic,
|
||||||
|
file_size_kb=file_size_kb,
|
||||||
|
estimated_tokens=estimated_tokens,
|
||||||
|
balance_before=balance,
|
||||||
|
balance_after=balance_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_quote(
|
||||||
|
user_id: int | None,
|
||||||
|
plan: str,
|
||||||
|
tool: str,
|
||||||
|
file_size_kb: float = 0,
|
||||||
|
estimated_tokens: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a non-binding estimate (for the frontend pre-upload panel).
|
||||||
|
|
||||||
|
Does NOT lock or deduct anything — purely informational.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
quote = create_quote(user_id, plan, tool, file_size_kb, estimated_tokens)
|
||||||
|
result = quote.to_dict()
|
||||||
|
result["affordable"] = True
|
||||||
|
return result
|
||||||
|
except QuoteError as exc:
|
||||||
|
base = get_tool_credit_cost(tool)
|
||||||
|
dynamic = is_dynamic_tool(tool)
|
||||||
|
if dynamic:
|
||||||
|
quoted = calculate_dynamic_cost(tool, file_size_kb, estimated_tokens)
|
||||||
|
else:
|
||||||
|
quoted = base
|
||||||
|
balance = get_rolling_balance(user_id, plan) if user_id else 0
|
||||||
|
return {
|
||||||
|
"tool": tool,
|
||||||
|
"quoted_credits": quoted,
|
||||||
|
"charged_credits": quoted,
|
||||||
|
"welcome_bonus_applied": False,
|
||||||
|
"is_dynamic": dynamic,
|
||||||
|
"file_size_kb": round(file_size_kb, 1),
|
||||||
|
"balance_before": balance,
|
||||||
|
"balance_after": balance - quoted,
|
||||||
|
"affordable": False,
|
||||||
|
"reason": exc.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fulfill_quote(
|
||||||
|
quote: CreditQuote,
|
||||||
|
user_id: int,
|
||||||
|
plan: str,
|
||||||
|
) -> int:
|
||||||
|
"""Deduct credits based on a locked quote. Returns credits charged.
|
||||||
|
|
||||||
|
If welcome bonus is applied, the bonus is consumed atomically and
|
||||||
|
zero credits are deducted from the rolling window.
|
||||||
|
"""
|
||||||
|
if quote.welcome_bonus_applied:
|
||||||
|
consumed = consume_welcome_bonus(user_id)
|
||||||
|
if not consumed:
|
||||||
|
# Race condition: bonus was consumed between quote and fulfill.
|
||||||
|
# Fall back to normal deduction.
|
||||||
|
logger.warning(
|
||||||
|
"Welcome bonus race for user %d — falling back to normal deduction",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return deduct_credits_quoted(user_id, plan, quote.quoted_credits)
|
||||||
|
logger.info(
|
||||||
|
"Welcome bonus applied for user %d, tool=%s, cost=%d waived",
|
||||||
|
user_id,
|
||||||
|
quote.tool,
|
||||||
|
quote.quoted_credits,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if quote.charged_credits == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return deduct_credits_quoted(user_id, plan, quote.charged_credits)
|
||||||
@@ -33,6 +33,7 @@ const staticPages = [
|
|||||||
{ path: '/privacy', changefreq: 'yearly', priority: '0.3' },
|
{ path: '/privacy', changefreq: 'yearly', priority: '0.3' },
|
||||||
{ path: '/terms', changefreq: 'yearly', priority: '0.3' },
|
{ path: '/terms', changefreq: 'yearly', priority: '0.3' },
|
||||||
{ path: '/pricing', changefreq: 'monthly', priority: '0.7' },
|
{ path: '/pricing', changefreq: 'monthly', priority: '0.7' },
|
||||||
|
{ path: '/pricing-transparency', changefreq: 'monthly', priority: '0.7' },
|
||||||
{ path: '/blog', changefreq: 'weekly', priority: '0.6' },
|
{ path: '/blog', changefreq: 'weekly', priority: '0.6' },
|
||||||
{ path: '/developers', changefreq: 'monthly', priority: '0.5' },
|
{ path: '/developers', changefreq: 'monthly', priority: '0.5' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const AccountPage = lazy(() => import('@/pages/AccountPage'));
|
|||||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
||||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||||
const PricingPage = lazy(() => import('@/pages/PricingPage'));
|
const PricingPage = lazy(() => import('@/pages/PricingPage'));
|
||||||
|
const PricingTransparencyPage = lazy(() => import('@/pages/PricingTransparencyPage'));
|
||||||
const BlogPage = lazy(() => import('@/pages/BlogPage'));
|
const BlogPage = lazy(() => import('@/pages/BlogPage'));
|
||||||
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
|
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
|
||||||
const DevelopersPage = lazy(() => import('@/pages/DevelopersPage'));
|
const DevelopersPage = lazy(() => import('@/pages/DevelopersPage'));
|
||||||
@@ -115,6 +116,7 @@ export default function App() {
|
|||||||
<Route path="/terms" element={<TermsPage />} />
|
<Route path="/terms" element={<TermsPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/pricing" element={<PricingPage />} />
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/pricing-transparency" element={<PricingTransparencyPage />} />
|
||||||
<Route path="/blog" element={<BlogPage />} />
|
<Route path="/blog" element={<BlogPage />} />
|
||||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||||
<Route path="/developers" element={<DevelopersPage />} />
|
<Route path="/developers" element={<DevelopersPage />} />
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
{t('common.pricing')}
|
{t('common.pricing')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/pricing-transparency"
|
||||||
|
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||||
|
>
|
||||||
|
{t('common.pricingTransparency')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/blog"
|
to="/blog"
|
||||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound } from 'lucide-react';
|
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound, Coins } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { ensureLanguageResources } from '@/i18n';
|
import { ensureLanguageResources } from '@/i18n';
|
||||||
interface LangOption {
|
interface LangOption {
|
||||||
@@ -42,6 +42,7 @@ export default function Header() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const credits = useAuthStore((state) => state.credits);
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const langRef = useRef<HTMLDivElement>(null);
|
const langRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -110,6 +111,12 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<UserRound className="h-4 w-4" />
|
<UserRound className="h-4 w-4" />
|
||||||
<span className="truncate">{user?.email || t('common.account')}</span>
|
<span className="truncate">{user?.email || t('common.account')}</span>
|
||||||
|
{user && credits && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{credits.credits_remaining}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Dark Mode Toggle */}
|
{/* Dark Mode Toggle */}
|
||||||
@@ -193,9 +200,15 @@ export default function Header() {
|
|||||||
<Link
|
<Link
|
||||||
to="/account"
|
to="/account"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
{user?.email || t('common.account')}
|
<span>{user?.email || t('common.account')}</span>
|
||||||
|
{user && credits && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{credits.credits_remaining}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/developers"
|
to="/developers"
|
||||||
|
|||||||
78
frontend/src/components/shared/CostEstimatePanel.tsx
Normal file
78
frontend/src/components/shared/CostEstimatePanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Coins, Sparkles, AlertTriangle } from 'lucide-react';
|
||||||
|
import { estimateCost, type CostEstimate } from '@/services/api';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
interface CostEstimatePanelProps {
|
||||||
|
toolSlug: string;
|
||||||
|
file: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CostEstimatePanel({ toolSlug, file }: CostEstimatePanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !file) {
|
||||||
|
setEstimate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const fileSizeKb = Math.ceil(file.size / 1024);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
estimateCost(toolSlug, fileSizeKb)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setEstimate(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setEstimate(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [user, file, toolSlug]);
|
||||||
|
|
||||||
|
if (!user || !file || loading || !estimate) return null;
|
||||||
|
|
||||||
|
const isAffordable = estimate.affordable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-3 flex items-center justify-between rounded-xl p-3 text-sm ring-1 ${
|
||||||
|
isAffordable
|
||||||
|
? 'bg-primary-50 ring-primary-200 dark:bg-primary-900/20 dark:ring-primary-800'
|
||||||
|
: 'bg-red-50 ring-red-200 dark:bg-red-900/20 dark:ring-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className={`h-4 w-4 ${isAffordable ? 'text-primary-600 dark:text-primary-400' : 'text-red-500'}`} />
|
||||||
|
<span className={isAffordable ? 'text-slate-700 dark:text-slate-300' : 'text-red-700 dark:text-red-400'}>
|
||||||
|
{t('costEstimate.cost')}: <strong>{estimate.quoted_credits}</strong> {t('costEstimate.credits')}
|
||||||
|
{estimate.welcome_bonus_applied && (
|
||||||
|
<span className="ms-1.5 inline-flex items-center gap-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
{t('costEstimate.firstFree')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{isAffordable ? (
|
||||||
|
<span>{t('costEstimate.remaining')}: {estimate.balance_after}</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{t('costEstimate.insufficient')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { UserPlus, LogIn, X, Loader2 } from 'lucide-react';
|
import { UserPlus, LogIn, X, Loader2, PartyPopper } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { claimTask } from '@/services/api';
|
import { claimTask } from '@/services/api';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export default function SignUpToDownloadModal({
|
|||||||
}: SignUpToDownloadModalProps) {
|
}: SignUpToDownloadModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { login, register } = useAuthStore();
|
const { login, register } = useAuthStore();
|
||||||
|
const isNewAccount = useAuthStore((state) => state.isNewAccount);
|
||||||
|
const clearNewAccount = useAuthStore((state) => state.clearNewAccount);
|
||||||
|
|
||||||
const [mode, setMode] = useState<'register' | 'login'>('register');
|
const [mode, setMode] = useState<'register' | 'login'>('register');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -53,6 +56,16 @@ export default function SignUpToDownloadModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcome celebration for new sign-ups
|
||||||
|
if (mode === 'register' && isNewAccount) {
|
||||||
|
toast(t('account.welcomeTitle'), {
|
||||||
|
description: t('account.welcomeMessage'),
|
||||||
|
icon: <PartyPopper className="h-5 w-5 text-amber-500" />,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
clearNewAccount();
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('account.loadFailed'));
|
setError(err instanceof Error ? err.message : t('account.loadFailed'));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { LucideIcon, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
import { LucideIcon, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
||||||
import FileUploader from '@/components/shared/FileUploader';
|
import FileUploader from '@/components/shared/FileUploader';
|
||||||
|
import CostEstimatePanel from '@/components/shared/CostEstimatePanel';
|
||||||
import ProgressBar from '@/components/shared/ProgressBar';
|
import ProgressBar from '@/components/shared/ProgressBar';
|
||||||
import DownloadButton from '@/components/shared/DownloadButton';
|
import DownloadButton from '@/components/shared/DownloadButton';
|
||||||
import AdSlot from '@/components/layout/AdSlot';
|
import AdSlot from '@/components/layout/AdSlot';
|
||||||
@@ -157,6 +158,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
|
|||||||
) || {}}
|
) || {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CostEstimatePanel toolSlug={config.slug} file={file} />
|
||||||
|
|
||||||
{children && (
|
{children && (
|
||||||
<div className="rounded-xl bg-slate-50 p-6 dark:bg-slate-800">{children(templateProps)}</div>
|
<div className="rounded-xl bg-slate-50 p-6 dark:bg-slate-800">{children(templateProps)}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const STATIC_PAGE_ROUTES = [
|
|||||||
'/developers',
|
'/developers',
|
||||||
'/tools',
|
'/tools',
|
||||||
'/internal/admin',
|
'/internal/admin',
|
||||||
|
'/pricing-transparency',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SEO_PAGE_ROUTES = getAllSeoLandingPaths();
|
const SEO_PAGE_ROUTES = getAllSeoLandingPaths();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"lightMode": "الوضع الفاتح",
|
"lightMode": "الوضع الفاتح",
|
||||||
"contact": "اتصل بنا",
|
"contact": "اتصل بنا",
|
||||||
"pricing": "الأسعار",
|
"pricing": "الأسعار",
|
||||||
|
"pricingTransparency": "كيف يعمل التسعير",
|
||||||
"blog": "المدونة",
|
"blog": "المدونة",
|
||||||
"developers": "للمطورين",
|
"developers": "للمطورين",
|
||||||
"send": "إرسال",
|
"send": "إرسال",
|
||||||
@@ -299,9 +300,9 @@
|
|||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"metaTitle": "الأسعار — Dociva",
|
"metaTitle": "الأسعار — Dociva",
|
||||||
"metaDescription": "قارن بين الخطة المجانية والاحترافية لـ Dociva. استخدم أكثر من 30 أداة مجانًا أو قم بالترقية للمعالجة غير المحدودة.",
|
"metaDescription": "قارن بين الخطة المجانية والاحترافية، وافهم كيف يعمل رصيد Dociva ولماذا قد تختلف تكلفة الأدوات بحسب عبء المعالجة.",
|
||||||
"title": "الخطط والأسعار",
|
"title": "الخطط والأسعار",
|
||||||
"subtitle": "ابدأ مجانًا وقم بالترقية عندما تحتاج المزيد.",
|
"subtitle": "ابدأ مجانًا، وراقب رصيدك بوضوح، وقم بالترقية فقط عندما تحتاج سعة معالجة أكبر.",
|
||||||
"free": "مجاني",
|
"free": "مجاني",
|
||||||
"pro": "احترافي",
|
"pro": "احترافي",
|
||||||
"freePrice": "$0",
|
"freePrice": "$0",
|
||||||
@@ -339,6 +340,15 @@
|
|||||||
"emailSupport": "دعم عبر البريد الإلكتروني"
|
"emailSupport": "دعم عبر البريد الإلكتروني"
|
||||||
},
|
},
|
||||||
"faqTitle": "الأسئلة الشائعة",
|
"faqTitle": "الأسئلة الشائعة",
|
||||||
|
"faq1q": "هل الخطة المجانية مجانية فعلًا؟",
|
||||||
|
"faq1a": "نعم. يحصل الحساب المجاني على 50 رصيدًا كل 30 يومًا، وتبقى جميع الأدوات الأساسية متاحة بدون بطاقة ائتمان.",
|
||||||
|
"faq2q": "هل يمكنني إلغاء خطة Pro في أي وقت؟",
|
||||||
|
"faq2a": "نعم. يمكنك الإلغاء في أي وقت، وسيعود الحساب إلى الخطة المجانية عند نهاية فترة الفوترة الحالية.",
|
||||||
|
"faq3q": "ما وسائل الدفع المقبولة؟",
|
||||||
|
"faq3a": "نقبل بطاقات الائتمان والخصم الرئيسية عبر Stripe، وتتم معالجة المدفوعات بأمان دون كشف بيانات البطاقة لـ Dociva.",
|
||||||
|
"transparencyTitle": "اعرف بالضبط كيف يُحتسب الرصيد",
|
||||||
|
"transparencyBody": "اقرأ الشرح الكامل للفارق بين التسعير الثابت والمتغير، وعرض التكلفة قبل التنفيذ، ولماذا نربط السعر بعبء المعالجة لا بعدد معاملات جامد.",
|
||||||
|
"transparencyAction": "كيف يعمل التسعير",
|
||||||
"faq": [
|
"faq": [
|
||||||
{
|
{
|
||||||
"q": "هل الخطة المجانية مجانية فعلًا؟",
|
"q": "هل الخطة المجانية مجانية فعلًا؟",
|
||||||
@@ -354,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"trustTitle": "مصمم للفرق التي تحتاج سرعة ووضوحًا في النتائج",
|
"trustTitle": "مصمم للفرق التي تحتاج سرعة ووضوحًا في النتائج",
|
||||||
"trustSubtitle": "المنصة نفسها تدعم الاستخدام السريع من المتصفح، والعمل المتكرر عبر الحساب، وتدفقات المستندات عبر API.",
|
"trustSubtitle": "المنصة نفسها تدعم المهام السريعة من المتصفح، والعمل المتكرر عبر الحساب، ونموذج رصيد يعكس عبء المعالجة الفعلي بصورة أوضح.",
|
||||||
"trustFastTitle": "معالجة سريعة",
|
"trustFastTitle": "معالجة سريعة",
|
||||||
"trustFastDesc": "تنفيذ المهام غير المتزامنة والعمال المحسّنون يساعدان على استمرار الأعمال الثقيلة دون تعطيل الواجهة.",
|
"trustFastDesc": "تنفيذ المهام غير المتزامنة والعمال المحسّنون يساعدان على استمرار الأعمال الثقيلة دون تعطيل الواجهة.",
|
||||||
"trustPrivateTitle": "الخصوصية افتراضيًا",
|
"trustPrivateTitle": "الخصوصية افتراضيًا",
|
||||||
@@ -362,6 +372,45 @@
|
|||||||
"trustApiTitle": "جاهز للتكامل",
|
"trustApiTitle": "جاهز للتكامل",
|
||||||
"trustApiDesc": "يمكن لمساحات العمل الاحترافية إنشاء مفاتيح API وربط الأدوات نفسها مع الأتمتة الداخلية أو تدفقات العملاء."
|
"trustApiDesc": "يمكن لمساحات العمل الاحترافية إنشاء مفاتيح API وربط الأدوات نفسها مع الأتمتة الداخلية أو تدفقات العملاء."
|
||||||
},
|
},
|
||||||
|
"pricingTransparency": {
|
||||||
|
"metaTitle": "شفافية التسعير",
|
||||||
|
"metaDescription": "افهم كيف يعمل رصيد Dociva، وما الذي يبقى ثابت السعر، وما الذي قد يتغير حسب عبء المعالجة، ولماذا لا نربط العملة بعدد ثابت من طلبات المزود.",
|
||||||
|
"badge": "شفافية التسعير",
|
||||||
|
"title": "كيف يعمل تسعير الرصيد في Dociva",
|
||||||
|
"subtitle": "نريد أن يعكس رصيدك الجهد الفعلي للمعالجة، لا مجرد عدّاد معاملات اعتباطي. هنا نشرح ما هو ثابت، وما الذي قد يتغير، وما الذي تراه قبل تنفيذ المهمة.",
|
||||||
|
"creditsTitle": "الرصيد هو ميزان استخدامك",
|
||||||
|
"creditsBody": "الحسابات المجانية والاحترافية تحصل على رصيد لكل نافذة مدتها 30 يومًا، ولا ينخفض الرصيد إلا عند قبول المهمة فعليًا.",
|
||||||
|
"quoteTitle": "ترى السعر قبل تشغيل المهام الثقيلة",
|
||||||
|
"quoteBody": "في الأدوات الثقيلة وأدوات الذكاء الاصطناعي المدعومة، يمكن لـ Dociva عرض تكلفة مسبقة قبل التنفيذ حتى تتخذ القرار على بينة.",
|
||||||
|
"fairnessTitle": "ليست كل مهمة متساوية في الكلفة",
|
||||||
|
"fairnessBody": "دمج ملف PDF ليس مثل ترجمة مستند طويل. لهذا نحاول أن يعكس الرصيد الفرق الحقيقي في عبء العمل.",
|
||||||
|
"howTitle": "كيف تُحسب التكلفة",
|
||||||
|
"howBody": "الأدوات الخفيفة والمتوسطة غالبًا ما تبقى على تكلفة ثابتة. أما الأدوات الثقيلة وأدوات الذكاء الاصطناعي فقد تستخدم تكلفة متغيرة تعتمد على حجم الملف أو عبء المعالجة المتوقع قبل إدخال المهمة إلى الصف.",
|
||||||
|
"fixedTitle": "أدوات ثابتة التكلفة",
|
||||||
|
"fixedBody": "المهام البسيطة مثل بعض تعديلات PDF أو التحويلات الصغيرة تحافظ على تكلفة منخفضة ومتوقعة حتى تبقى سير العمل اليومي سهلة في التخطيط.",
|
||||||
|
"dynamicTitle": "أدوات متغيرة التكلفة",
|
||||||
|
"dynamicBody": "المهام الأعلى استهلاكًا مثل OCR أو الضغط أو بعض تدفقات الذكاء الاصطناعي قد ترتفع تكلفتها عندما يكبر الملف أو يزداد عبء المعالجة.",
|
||||||
|
"noteTitle": "ما نريد قوله بوضوح",
|
||||||
|
"noteBody": "رصيد الموقع هو رصيد منتج لاستخدام Dociva. وهو ليس عداد توكنات مباشرًا من مزودي البنية التحتية الذين نعتمد عليهم داخليًا.",
|
||||||
|
"noOneToOneBody": "لا توجد قاعدة ثابتة من نوع \"1 رصيد = X طلبات OpenRouter\". بعض المهام ترسل طلبًا واحدًا، وبعضها عدة طلبات، وبعض التدفقات المسعرة كذكاء اصطناعي لا تعتمد على OpenRouter أصلًا.",
|
||||||
|
"examplesTitle": "ثلاثة أمثلة بسيطة",
|
||||||
|
"exampleLightTitle": "تكلفة منخفضة وثابتة",
|
||||||
|
"exampleLightBody": "الأداة الخفيفة مثل دمج PDF أو تدويره تبقى قريبة من الحد الأدنى لأن عبء المعالجة منخفض ويمكن التنبؤ به.",
|
||||||
|
"exampleHeavyTitle": "معالجة أثقل",
|
||||||
|
"exampleHeavyBody": "OCR أو الضغط أو إزالة الخلفية قد تكلف أكثر لأن حجم الملف وزمن المعالجة يرفعان عبء العمل الحقيقي.",
|
||||||
|
"exampleAiTitle": "تدفقات مدعومة بالذكاء الاصطناعي",
|
||||||
|
"exampleAiBody": "الدردشة أو التلخيص أو الترجمة تبدأ عادة من حد أعلى لأنها تتضمن معالجة لغوية، وقد تتطلب أحيانًا أكثر من نداء واحد إلى مزود خارجي.",
|
||||||
|
"futureTitle": "لماذا يدعم هذا فكرة الدفع حسب الاستخدام",
|
||||||
|
"futureBody": "اليوم ما زال المنتج يعمل عبر Free وPro، لكن فلسفة التسعير نفسها تتجه إلى عدالة أعلى: المهمة الأسهل يجب أن تستهلك رصيدًا أقل من المهمة الأعلى كلفة. هذه هي القاعدة التي يمكن البناء عليها لاحقًا لإعادة الشحن بمرونة أكبر.",
|
||||||
|
"faq1q": "هل سأعرف التكلفة دائمًا قبل التنفيذ؟",
|
||||||
|
"faq1a": "في الأدوات الثقيلة وأدوات الذكاء الاصطناعي المدعومة، يمكن لـ Dociva عرض quote مسبق قبل dispatch. أما الأدوات الأبسط فتبقى على تكلفة ثابتة واضحة.",
|
||||||
|
"faq2q": "ماذا يحدث إذا كان الرصيد المتبقي منخفضًا؟",
|
||||||
|
"faq2a": "يتم رفض المهمة قبل أن تبدأ المعالجة، حتى تتمكن من مراجعة رصيدك أو الترقية بدون إنفاق أعمى للرصد.",
|
||||||
|
"faq3q": "لماذا لا تجعلون كل شيء معاملة واحدة فقط؟",
|
||||||
|
"faq3a": "العدّ الثابت للمعاملات أسهل في الشرح، لكنه أقل عدلًا. التسعير المرتبط بعبء العمل يساعد على إبقاء الأدوات البسيطة ميسورة من دون تسعير ناقص للمهام الأعلى كلفة.",
|
||||||
|
"pricingCta": "قارن بين Free وPro",
|
||||||
|
"toolsCta": "استكشف الأدوات"
|
||||||
|
},
|
||||||
"developers": {
|
"developers": {
|
||||||
"metaDescription": "استكشف بوابة مطوري Dociva، وتدفق API غير المتزامن، والنقاط الجاهزة لأتمتة المستندات.",
|
"metaDescription": "استكشف بوابة مطوري Dociva، وتدفق API غير المتزامن، والنقاط الجاهزة لأتمتة المستندات.",
|
||||||
"badge": "بوابة المطورين",
|
"badge": "بوابة المطورين",
|
||||||
@@ -1020,7 +1069,9 @@
|
|||||||
"originalFile": "الملف الأصلي",
|
"originalFile": "الملف الأصلي",
|
||||||
"outputFile": "الملف الناتج",
|
"outputFile": "الملف الناتج",
|
||||||
"statusCompleted": "مكتمل",
|
"statusCompleted": "مكتمل",
|
||||||
"statusFailed": "فشل"
|
"statusFailed": "فشل",
|
||||||
|
"welcomeTitle": "🎉 مرحباً بك في Dociva!",
|
||||||
|
"welcomeMessage": "لديك 50 عملة رصيد مجاني ومعاملتك الأولى علينا. ابدأ معالجة ملفاتك الآن!"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"conversionComplete": "اكتمل التحويل!",
|
"conversionComplete": "اكتمل التحويل!",
|
||||||
@@ -1522,5 +1573,12 @@
|
|||||||
{"q": "ما الفرق عن رمز QR؟", "a": "الباركود خطي (أحادي البعد) بسعة بيانات أقل. رموز QR ثنائية الأبعاد وتخزن معلومات أكثر."}
|
{"q": "ما الفرق عن رمز QR؟", "a": "الباركود خطي (أحادي البعد) بسعة بيانات أقل. رموز QR ثنائية الأبعاد وتخزن معلومات أكثر."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"costEstimate": {
|
||||||
|
"cost": "التكلفة",
|
||||||
|
"credits": "رصيد",
|
||||||
|
"firstFree": "الأولى مجاناً!",
|
||||||
|
"remaining": "المتبقي",
|
||||||
|
"insufficient": "رصيد غير كافٍ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"lightMode": "Light Mode",
|
"lightMode": "Light Mode",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
|
"pricingTransparency": "How Pricing Works",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"developers": "Developers",
|
"developers": "Developers",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
@@ -299,9 +300,9 @@
|
|||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"metaTitle": "Pricing — Dociva",
|
"metaTitle": "Pricing — Dociva",
|
||||||
"metaDescription": "Compare free and pro plans for Dociva. Access 30+ tools for free, or upgrade for unlimited processing.",
|
"metaDescription": "Compare Free and Pro plans, understand how Dociva credits work, and see why heavier workloads can cost more than lightweight tasks.",
|
||||||
"title": "Plans & Pricing",
|
"title": "Plans & Pricing",
|
||||||
"subtitle": "Start free and upgrade when you need more.",
|
"subtitle": "Start free, track credits clearly, and upgrade only when you need more processing capacity.",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"freePrice": "$0",
|
"freePrice": "$0",
|
||||||
@@ -339,6 +340,15 @@
|
|||||||
"emailSupport": "Email support"
|
"emailSupport": "Email support"
|
||||||
},
|
},
|
||||||
"faqTitle": "Frequently Asked Questions",
|
"faqTitle": "Frequently Asked Questions",
|
||||||
|
"faq1q": "Is the Free plan really free?",
|
||||||
|
"faq1a": "Yes. Free accounts receive 50 credits every 30 days, and all core tools remain available without a credit card.",
|
||||||
|
"faq2q": "Can I cancel the Pro plan anytime?",
|
||||||
|
"faq2a": "Yes. Cancel anytime and your account returns to the Free plan at the end of the current billing period.",
|
||||||
|
"faq3q": "What payment methods do you accept?",
|
||||||
|
"faq3a": "We accept major credit and debit cards through Stripe. Payments are processed securely without exposing your card data to Dociva.",
|
||||||
|
"transparencyTitle": "See exactly how credits are counted",
|
||||||
|
"transparencyBody": "Read the full explanation of fixed vs dynamic pricing, pre-run quotes, and why we price by workload instead of a rigid transaction count.",
|
||||||
|
"transparencyAction": "How pricing works",
|
||||||
"faq": [
|
"faq": [
|
||||||
{
|
{
|
||||||
"q": "Is the free plan really free?",
|
"q": "Is the free plan really free?",
|
||||||
@@ -354,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"trustTitle": "Built for teams that need speed and predictability",
|
"trustTitle": "Built for teams that need speed and predictability",
|
||||||
"trustSubtitle": "The same platform powers quick browser workflows, recurring account usage, and API-driven document pipelines.",
|
"trustSubtitle": "The same platform supports quick browser tasks, recurring account usage, and a credit model designed to reflect actual processing workload.",
|
||||||
"trustFastTitle": "Fast processing",
|
"trustFastTitle": "Fast processing",
|
||||||
"trustFastDesc": "Async task handling and optimized workers keep heavy jobs moving without blocking the interface.",
|
"trustFastDesc": "Async task handling and optimized workers keep heavy jobs moving without blocking the interface.",
|
||||||
"trustPrivateTitle": "Private by default",
|
"trustPrivateTitle": "Private by default",
|
||||||
@@ -362,6 +372,45 @@
|
|||||||
"trustApiTitle": "Ready for integration",
|
"trustApiTitle": "Ready for integration",
|
||||||
"trustApiDesc": "Pro workspaces can generate API keys and connect the same tools to internal automations and client flows."
|
"trustApiDesc": "Pro workspaces can generate API keys and connect the same tools to internal automations and client flows."
|
||||||
},
|
},
|
||||||
|
"pricingTransparency": {
|
||||||
|
"metaTitle": "Pricing Transparency",
|
||||||
|
"metaDescription": "Understand how Dociva credits work, what stays fixed, what can vary by workload, and why credits are not tied to a fixed number of provider requests.",
|
||||||
|
"badge": "Pricing Transparency",
|
||||||
|
"title": "How credit pricing works at Dociva",
|
||||||
|
"subtitle": "We want your balance to reflect real processing effort, not an arbitrary transaction counter. This page explains what is fixed, what can vary, and what you see before you run a task.",
|
||||||
|
"creditsTitle": "Credits are your usage balance",
|
||||||
|
"creditsBody": "Free and Pro accounts receive a credit allocation for each 30-day window. Your balance decreases only when a task is accepted.",
|
||||||
|
"quoteTitle": "You see the price before heavy jobs run",
|
||||||
|
"quoteBody": "For supported heavy and AI tools, Dociva can show a quote before execution so you can decide with context.",
|
||||||
|
"fairnessTitle": "Not every task should cost the same",
|
||||||
|
"fairnessBody": "Merging a PDF and translating a long document do not consume the same resources. Credit pricing reflects that difference.",
|
||||||
|
"howTitle": "How costs are calculated",
|
||||||
|
"howBody": "Light and medium tools usually keep a fixed credit cost. Heavy and AI tools can use a dynamic quote based on the file size or processing workload expected before the task is queued.",
|
||||||
|
"fixedTitle": "Fixed-cost tools",
|
||||||
|
"fixedBody": "Tasks like simple PDF edits or small conversions keep a predictable, low cost so common workflows stay easy to budget.",
|
||||||
|
"dynamicTitle": "Dynamic-cost tools",
|
||||||
|
"dynamicBody": "Resource-heavy tasks such as OCR, compression, or AI-assisted document workflows may cost more when the file or workload is larger.",
|
||||||
|
"noteTitle": "What we want to be explicit about",
|
||||||
|
"noteBody": "Your site credits are a product balance for Dociva usage. They are not a direct token meter from our infrastructure providers.",
|
||||||
|
"noOneToOneBody": "There is no fixed rule such as \"1 credit = X OpenRouter requests.\" Some jobs make one provider call, some make several, and some AI-priced workflows do not rely on OpenRouter at all.",
|
||||||
|
"examplesTitle": "Three simple examples",
|
||||||
|
"exampleLightTitle": "Low-cost, fixed",
|
||||||
|
"exampleLightBody": "A lightweight tool such as merging or rotating a PDF stays near the minimum price because the processing burden is low and predictable.",
|
||||||
|
"exampleHeavyTitle": "Heavier processing",
|
||||||
|
"exampleHeavyBody": "OCR, compression, or background removal may cost more because file size and computation time increase the real workload.",
|
||||||
|
"exampleAiTitle": "AI-assisted workflows",
|
||||||
|
"exampleAiBody": "Chat, summarization, or translation start from a higher base because they involve language processing and, in some cases, multiple provider calls.",
|
||||||
|
"futureTitle": "Why this supports pay-as-you-use thinking",
|
||||||
|
"futureBody": "Today the product still uses Free and Pro plans, but the pricing philosophy already aims at fairer usage: easier tasks should consume less balance than expensive ones. That is the foundation for future recharge flexibility.",
|
||||||
|
"faq1q": "Will I always know the cost in advance?",
|
||||||
|
"faq1a": "For supported heavy and AI tools, Dociva can show a pre-run quote before dispatch. Simpler tools keep a fixed credit cost.",
|
||||||
|
"faq2q": "What happens when my balance is too low?",
|
||||||
|
"faq2a": "The task is rejected before processing starts, so you can review your balance or upgrade without spending credits blindly.",
|
||||||
|
"faq3q": "Why not price everything as one transaction?",
|
||||||
|
"faq3a": "A flat transaction count is easier to explain but less fair. Workload-aware pricing helps keep simple tools affordable without underpricing expensive jobs.",
|
||||||
|
"pricingCta": "Compare Free and Pro",
|
||||||
|
"toolsCta": "Explore tools"
|
||||||
|
},
|
||||||
"developers": {
|
"developers": {
|
||||||
"metaDescription": "Explore the Dociva developer portal, async API flow, and production-ready endpoints for document automation.",
|
"metaDescription": "Explore the Dociva developer portal, async API flow, and production-ready endpoints for document automation.",
|
||||||
"badge": "Developer Portal",
|
"badge": "Developer Portal",
|
||||||
@@ -1020,7 +1069,9 @@
|
|||||||
"originalFile": "Original file",
|
"originalFile": "Original file",
|
||||||
"outputFile": "Output file",
|
"outputFile": "Output file",
|
||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"statusFailed": "Failed"
|
"statusFailed": "Failed",
|
||||||
|
"welcomeTitle": "🎉 Welcome to Dociva!",
|
||||||
|
"welcomeMessage": "You have 50 free credits and your first transaction is on us. Start processing files now!"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"conversionComplete": "Conversion Complete!",
|
"conversionComplete": "Conversion Complete!",
|
||||||
@@ -1532,5 +1583,12 @@
|
|||||||
{"q": "What is the difference from a QR code?", "a": "Barcodes are linear (1D) with less data capacity. QR codes are 2D and store more information."}
|
{"q": "What is the difference from a QR code?", "a": "Barcodes are linear (1D) with less data capacity. QR codes are 2D and store more information."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"costEstimate": {
|
||||||
|
"cost": "Cost",
|
||||||
|
"credits": "credits",
|
||||||
|
"firstFree": "First free!",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"insufficient": "Insufficient credits"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"lightMode": "Mode clair",
|
"lightMode": "Mode clair",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"pricing": "Tarifs",
|
"pricing": "Tarifs",
|
||||||
|
"pricingTransparency": "Comment fonctionne la tarification",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"developers": "Développeurs",
|
"developers": "Développeurs",
|
||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
@@ -299,9 +300,9 @@
|
|||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"metaTitle": "Tarifs — Dociva",
|
"metaTitle": "Tarifs — Dociva",
|
||||||
"metaDescription": "Comparez les plans gratuit et pro de Dociva. Accédez à plus de 30 outils gratuitement ou passez au pro pour un traitement illimité.",
|
"metaDescription": "Comparez les plans Gratuit et Pro, comprenez comment fonctionnent les crédits Dociva et pourquoi certaines charges de travail coûtent plus que des tâches légères.",
|
||||||
"title": "Plans & Tarifs",
|
"title": "Plans & Tarifs",
|
||||||
"subtitle": "Commencez gratuitement et passez au pro quand vous en avez besoin.",
|
"subtitle": "Commencez gratuitement, suivez vos crédits clairement et passez au Pro seulement quand vous avez besoin de plus de capacité.",
|
||||||
"free": "Gratuit",
|
"free": "Gratuit",
|
||||||
"pro": "Pro",
|
"pro": "Pro",
|
||||||
"freePrice": "0€",
|
"freePrice": "0€",
|
||||||
@@ -339,6 +340,15 @@
|
|||||||
"emailSupport": "Support par e-mail"
|
"emailSupport": "Support par e-mail"
|
||||||
},
|
},
|
||||||
"faqTitle": "Questions fréquentes",
|
"faqTitle": "Questions fréquentes",
|
||||||
|
"faq1q": "Le plan gratuit est-il vraiment gratuit ?",
|
||||||
|
"faq1a": "Oui. Les comptes gratuits reçoivent 50 crédits tous les 30 jours et tous les outils essentiels restent disponibles sans carte bancaire.",
|
||||||
|
"faq2q": "Puis-je annuler le plan Pro à tout moment ?",
|
||||||
|
"faq2a": "Oui. Annulez quand vous voulez et votre compte repassera au plan Gratuit à la fin de la période de facturation en cours.",
|
||||||
|
"faq3q": "Quels moyens de paiement acceptez-vous ?",
|
||||||
|
"faq3a": "Nous acceptons les principales cartes bancaires via Stripe. Les paiements sont traités de manière sécurisée sans exposer vos données bancaires à Dociva.",
|
||||||
|
"transparencyTitle": "Voyez exactement comment les crédits sont comptés",
|
||||||
|
"transparencyBody": "Lisez l'explication complète de la tarification fixe vs dynamique, des devis avant exécution et de notre logique basée sur la charge de travail plutôt que sur un simple compteur de transactions.",
|
||||||
|
"transparencyAction": "Comment fonctionne la tarification",
|
||||||
"faq": [
|
"faq": [
|
||||||
{
|
{
|
||||||
"q": "Le plan gratuit est-il vraiment gratuit ?",
|
"q": "Le plan gratuit est-il vraiment gratuit ?",
|
||||||
@@ -354,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"trustTitle": "Conçu pour les équipes qui ont besoin de vitesse et de prévisibilité",
|
"trustTitle": "Conçu pour les équipes qui ont besoin de vitesse et de prévisibilité",
|
||||||
"trustSubtitle": "La même plateforme prend en charge les usages rapides dans le navigateur, les workflows récurrents liés au compte et les pipelines documentaires via API.",
|
"trustSubtitle": "La même plateforme prend en charge les tâches rapides dans le navigateur, l'usage récurrent lié au compte et un modèle de crédits pensé pour refléter la charge réelle de traitement.",
|
||||||
"trustFastTitle": "Traitement rapide",
|
"trustFastTitle": "Traitement rapide",
|
||||||
"trustFastDesc": "Les tâches asynchrones et les workers optimisés permettent aux traitements lourds d'avancer sans bloquer l'interface.",
|
"trustFastDesc": "Les tâches asynchrones et les workers optimisés permettent aux traitements lourds d'avancer sans bloquer l'interface.",
|
||||||
"trustPrivateTitle": "Privé par défaut",
|
"trustPrivateTitle": "Privé par défaut",
|
||||||
@@ -362,6 +372,45 @@
|
|||||||
"trustApiTitle": "Prêt pour l'intégration",
|
"trustApiTitle": "Prêt pour l'intégration",
|
||||||
"trustApiDesc": "Les espaces Pro peuvent générer des clés API et connecter les mêmes outils à des automatisations internes ou à des parcours clients."
|
"trustApiDesc": "Les espaces Pro peuvent générer des clés API et connecter les mêmes outils à des automatisations internes ou à des parcours clients."
|
||||||
},
|
},
|
||||||
|
"pricingTransparency": {
|
||||||
|
"metaTitle": "Transparence tarifaire",
|
||||||
|
"metaDescription": "Comprenez comment fonctionnent les crédits Dociva, ce qui reste à coût fixe, ce qui peut varier selon la charge de travail et pourquoi les crédits ne sont pas liés à un nombre fixe de requêtes fournisseur.",
|
||||||
|
"badge": "Transparence tarifaire",
|
||||||
|
"title": "Comment fonctionne la tarification par crédits chez Dociva",
|
||||||
|
"subtitle": "Nous voulons que votre solde reflète l'effort réel de traitement, et non un simple compteur arbitraire de transactions. Cette page explique ce qui est fixe, ce qui peut varier et ce que vous voyez avant d'exécuter une tâche.",
|
||||||
|
"creditsTitle": "Les crédits sont votre solde d'usage",
|
||||||
|
"creditsBody": "Les comptes Gratuit et Pro reçoivent une allocation de crédits pour chaque fenêtre de 30 jours. Votre solde ne baisse que lorsqu'une tâche est acceptée.",
|
||||||
|
"quoteTitle": "Vous voyez le prix avant les tâches lourdes",
|
||||||
|
"quoteBody": "Pour les outils lourds et IA pris en charge, Dociva peut afficher un devis avant l'exécution afin que vous décidiez en connaissance de cause.",
|
||||||
|
"fairnessTitle": "Toutes les tâches ne devraient pas coûter pareil",
|
||||||
|
"fairnessBody": "Fusionner un PDF et traduire un long document ne consomment pas les mêmes ressources. La tarification en crédits reflète cette différence.",
|
||||||
|
"howTitle": "Comment les coûts sont calculés",
|
||||||
|
"howBody": "Les outils légers et moyens gardent en général un coût fixe. Les outils lourds et IA peuvent utiliser un devis dynamique basé sur la taille du fichier ou la charge de traitement attendue avant la mise en file.",
|
||||||
|
"fixedTitle": "Outils à coût fixe",
|
||||||
|
"fixedBody": "Les tâches comme les petites modifications PDF ou les conversions simples gardent un coût prévisible et bas afin que les usages courants restent faciles à budgéter.",
|
||||||
|
"dynamicTitle": "Outils à coût variable",
|
||||||
|
"dynamicBody": "Les tâches gourmandes en ressources comme l'OCR, la compression ou certains workflows documentaires assistés par IA peuvent coûter plus lorsque le fichier ou la charge de traitement augmente.",
|
||||||
|
"noteTitle": "Ce que nous voulons dire clairement",
|
||||||
|
"noteBody": "Les crédits du site sont un solde produit pour l'usage de Dociva. Ce n'est pas un compteur direct de tokens facturé par nos fournisseurs d'infrastructure.",
|
||||||
|
"noOneToOneBody": "Il n'existe pas de règle fixe du type \"1 crédit = X requêtes OpenRouter\". Certains traitements font un seul appel fournisseur, d'autres plusieurs, et certains workflows tarifés comme IA n'utilisent même pas OpenRouter.",
|
||||||
|
"examplesTitle": "Trois exemples simples",
|
||||||
|
"exampleLightTitle": "Faible coût, fixe",
|
||||||
|
"exampleLightBody": "Un outil léger comme la fusion ou la rotation de PDF reste proche du prix minimum car la charge de traitement est faible et prévisible.",
|
||||||
|
"exampleHeavyTitle": "Traitement plus lourd",
|
||||||
|
"exampleHeavyBody": "L'OCR, la compression ou la suppression d'arrière-plan peuvent coûter plus cher car la taille du fichier et le temps de calcul augmentent la charge réelle.",
|
||||||
|
"exampleAiTitle": "Workflows assistés par IA",
|
||||||
|
"exampleAiBody": "Le chat, le résumé ou la traduction partent d'une base plus élevée car ils impliquent du traitement linguistique et, parfois, plusieurs appels à des fournisseurs externes.",
|
||||||
|
"futureTitle": "Pourquoi cela va dans le sens du pay-as-you-use",
|
||||||
|
"futureBody": "Aujourd'hui, le produit repose encore sur les plans Gratuit et Pro, mais la philosophie tarifaire vise déjà un usage plus juste: les tâches simples doivent consommer moins de solde que les tâches coûteuses. C'est la base d'une future flexibilité de recharge.",
|
||||||
|
"faq1q": "Connaîtrai-je toujours le coût à l'avance ?",
|
||||||
|
"faq1a": "Pour les outils lourds et IA pris en charge, Dociva peut afficher un devis avant dispatch. Les outils plus simples gardent un coût fixe.",
|
||||||
|
"faq2q": "Que se passe-t-il si mon solde est trop faible ?",
|
||||||
|
"faq2a": "La tâche est rejetée avant le début du traitement, afin que vous puissiez vérifier votre solde ou passer à un plan supérieur sans dépenser de crédits à l'aveugle.",
|
||||||
|
"faq3q": "Pourquoi ne pas tout facturer comme une seule transaction ?",
|
||||||
|
"faq3a": "Un compteur plat de transactions est plus simple à expliquer, mais moins juste. Une tarification liée à la charge de travail aide à garder les outils simples abordables sans sous-tarifer les traitements coûteux.",
|
||||||
|
"pricingCta": "Comparer Gratuit et Pro",
|
||||||
|
"toolsCta": "Explorer les outils"
|
||||||
|
},
|
||||||
"developers": {
|
"developers": {
|
||||||
"metaDescription": "Explorez le portail développeur Dociva, le flux API asynchrone et les endpoints prêts pour l'automatisation documentaire.",
|
"metaDescription": "Explorez le portail développeur Dociva, le flux API asynchrone et les endpoints prêts pour l'automatisation documentaire.",
|
||||||
"badge": "Portail développeur",
|
"badge": "Portail développeur",
|
||||||
@@ -1020,7 +1069,9 @@
|
|||||||
"originalFile": "Fichier source",
|
"originalFile": "Fichier source",
|
||||||
"outputFile": "Fichier de sortie",
|
"outputFile": "Fichier de sortie",
|
||||||
"statusCompleted": "Terminé",
|
"statusCompleted": "Terminé",
|
||||||
"statusFailed": "Échec"
|
"statusFailed": "Échec",
|
||||||
|
"welcomeTitle": "🎉 Bienvenue sur Dociva !",
|
||||||
|
"welcomeMessage": "Vous avez 50 crédits gratuits et votre première transaction est offerte. Commencez à traiter vos fichiers !"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"conversionComplete": "Conversion terminée !",
|
"conversionComplete": "Conversion terminée !",
|
||||||
@@ -1502,5 +1553,12 @@
|
|||||||
{"q": "Quelle est la différence avec un code QR ?", "a": "Les codes-barres sont linéaires (1D) avec moins de capacité de données. Les codes QR sont bidimensionnels (2D) et stockent plus d'informations."}
|
{"q": "Quelle est la différence avec un code QR ?", "a": "Les codes-barres sont linéaires (1D) avec moins de capacité de données. Les codes QR sont bidimensionnels (2D) et stockent plus d'informations."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"costEstimate": {
|
||||||
|
"cost": "Coût",
|
||||||
|
"credits": "crédits",
|
||||||
|
"firstFree": "Premier gratuit !",
|
||||||
|
"remaining": "Restant",
|
||||||
|
"insufficient": "Crédits insuffisants"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FolderClock,
|
FolderClock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
PartyPopper,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -94,6 +95,21 @@ export default function AccountPage() {
|
|||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const register = useAuthStore((state) => state.register);
|
const register = useAuthStore((state) => state.register);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
const isNewAccount = useAuthStore((state) => state.isNewAccount);
|
||||||
|
const clearNewAccount = useAuthStore((state) => state.clearNewAccount);
|
||||||
|
const credits = useAuthStore((state) => state.credits);
|
||||||
|
|
||||||
|
// Welcome celebration for new registrations
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNewAccount && user) {
|
||||||
|
toast(t('account.welcomeTitle'), {
|
||||||
|
description: t('account.welcomeMessage'),
|
||||||
|
icon: <PartyPopper className="h-5 w-5 text-amber-500" />,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
clearNewAccount();
|
||||||
|
}
|
||||||
|
}, [isNewAccount, user, t, clearNewAccount]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<AuthMode>('login');
|
const [mode, setMode] = useState<AuthMode>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import { Check, X, Zap, Crown, Loader2 } from 'lucide-react';
|
import { ArrowRight, Check, Coins, Crown, Loader2, Scale, X, Zap } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||||
import { getApiClient } from '@/services/api';
|
import { getApiClient } from '@/services/api';
|
||||||
@@ -88,6 +88,32 @@ export default function PricingPage() {
|
|||||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
||||||
{t('pages.pricing.subtitle', 'Start free with all tools. Upgrade when you need more power.')}
|
{t('pages.pricing.subtitle', 'Start free with all tools. Upgrade when you need more power.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-6 max-w-3xl rounded-2xl border border-primary-200 bg-primary-50/80 p-5 text-start shadow-sm dark:border-primary-900/40 dark:bg-primary-900/20">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="mt-0.5 rounded-2xl bg-white p-2 text-primary-600 shadow-sm dark:bg-slate-900 dark:text-primary-300">
|
||||||
|
<Scale className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricing.transparencyTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">
|
||||||
|
{t('pages.pricing.transparencyBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/pricing-transparency"
|
||||||
|
className="inline-flex items-center gap-2 self-start rounded-xl bg-white px-4 py-2 text-sm font-semibold text-primary-700 transition-colors hover:bg-primary-100 dark:bg-slate-900 dark:text-primary-300 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Coins className="h-4 w-4" />
|
||||||
|
{t('pages.pricing.transparencyAction')}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="deferred-section mb-12">
|
<div className="deferred-section mb-12">
|
||||||
|
|||||||
216
frontend/src/pages/PricingTransparencyPage.tsx
Normal file
216
frontend/src/pages/PricingTransparencyPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Coins,
|
||||||
|
Gauge,
|
||||||
|
Receipt,
|
||||||
|
Scale,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
|
|
||||||
|
export default function PricingTransparencyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEOHead
|
||||||
|
title={t('pages.pricingTransparency.metaTitle')}
|
||||||
|
description={t('pages.pricingTransparency.metaDescription')}
|
||||||
|
path="/pricing-transparency"
|
||||||
|
jsonLd={generateWebPage({
|
||||||
|
name: t('pages.pricingTransparency.metaTitle'),
|
||||||
|
description: t('pages.pricingTransparency.metaDescription'),
|
||||||
|
url: `${siteOrigin}/pricing-transparency`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
|
{t('pages.pricingTransparency.badge')}
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-5 max-w-3xl text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||||
|
{t('pages.pricingTransparency.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-600 dark:text-slate-400 sm:text-lg">
|
||||||
|
{t('pages.pricingTransparency.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<Coins className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="mt-3 text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.creditsTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.creditsBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<Receipt className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="mt-3 text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.quoteTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.quoteBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<Scale className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="mt-3 text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.fairnessTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.fairnessBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Gauge className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.howTitle')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-400 sm:text-base">
|
||||||
|
{t('pages.pricingTransparency.howBody')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-5 dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.fixedTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.fixedBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-5 dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.dynamicTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.dynamicBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.noteTitle')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-400 sm:text-base">
|
||||||
|
{t('pages.pricingTransparency.noteBody')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-2xl bg-primary-50 p-4 dark:bg-primary-900/20">
|
||||||
|
<p className="text-sm leading-6 text-primary-800 dark:text-primary-200">
|
||||||
|
{t('pages.pricingTransparency.noOneToOneBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.examplesTitle')}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.exampleLightTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.exampleLightBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.exampleHeavyTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.exampleHeavyBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-slate-50 p-5 dark:bg-slate-800/70">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.exampleAiTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.exampleAiBody')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Sparkles className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.futureTitle')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-7 text-slate-600 dark:text-slate-400 sm:text-base">
|
||||||
|
{t('pages.pricingTransparency.futureBody')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-5 dark:border-slate-700">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.faq1q')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.faq1a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-5 dark:border-slate-700">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.faq2q')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.faq2a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-5 dark:border-slate-700">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{t('pages.pricingTransparency.faq3q')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||||
|
{t('pages.pricingTransparency.faq3a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
{t('pages.pricingTransparency.pricingCta')}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/tools"
|
||||||
|
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
{t('pages.pricingTransparency.toolsCta')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -280,17 +280,54 @@ export interface AuthUser {
|
|||||||
plan: string;
|
plan: string;
|
||||||
role: 'user' | 'admin' | string;
|
role: 'user' | 'admin' | string;
|
||||||
is_allowlisted_admin?: boolean;
|
is_allowlisted_admin?: boolean;
|
||||||
|
welcome_bonus_available?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreditSummary {
|
||||||
|
credits_allocated: number;
|
||||||
|
credits_used: number;
|
||||||
|
credits_remaining: number;
|
||||||
|
window_start_at: string | null;
|
||||||
|
window_end_at: string | null;
|
||||||
|
plan: string;
|
||||||
|
window_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditQuote {
|
||||||
|
tool: string;
|
||||||
|
base_cost: number;
|
||||||
|
quoted_credits: number;
|
||||||
|
charged_credits: number;
|
||||||
|
welcome_bonus_applied: boolean;
|
||||||
|
is_dynamic: boolean;
|
||||||
|
file_size_kb: number | null;
|
||||||
|
estimated_tokens: number | null;
|
||||||
|
balance_before: number;
|
||||||
|
balance_after: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
tool: string;
|
||||||
|
quoted_credits: number;
|
||||||
|
is_dynamic: boolean;
|
||||||
|
balance_before: number;
|
||||||
|
balance_after: number;
|
||||||
|
welcome_bonus_applied: boolean;
|
||||||
|
affordable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
message: string;
|
message: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
credits?: CreditSummary;
|
||||||
|
is_new_account?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthSessionResponse {
|
interface AuthSessionResponse {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
credits?: CreditSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryResponse {
|
interface HistoryResponse {
|
||||||
@@ -454,21 +491,21 @@ export async function startTask(endpoint: string): Promise<TaskResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new account and return the authenticated user.
|
* Create a new account and return the auth response.
|
||||||
*/
|
*/
|
||||||
export async function registerUser(email: string, password: string): Promise<AuthUser> {
|
export async function registerUser(email: string, password: string): Promise<AuthResponse> {
|
||||||
const response = await api.post<AuthResponse>('/auth/register', { email, password });
|
const response = await api.post<AuthResponse>('/auth/register', { email, password });
|
||||||
await ensureCsrfToken(true);
|
await ensureCsrfToken(true);
|
||||||
return response.data.user;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign in and return the authenticated user.
|
* Sign in and return the auth response.
|
||||||
*/
|
*/
|
||||||
export async function loginUser(email: string, password: string): Promise<AuthUser> {
|
export async function loginUser(email: string, password: string): Promise<AuthResponse> {
|
||||||
const response = await api.post<AuthResponse>('/auth/login', { email, password });
|
const response = await api.post<AuthResponse>('/auth/login', { email, password });
|
||||||
await ensureCsrfToken(true);
|
await ensureCsrfToken(true);
|
||||||
return response.data.user;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,9 +530,9 @@ export async function claimTask(taskId: string, tool: string): Promise<{ claimed
|
|||||||
/**
|
/**
|
||||||
* Return the current authenticated user, if any.
|
* Return the current authenticated user, if any.
|
||||||
*/
|
*/
|
||||||
export async function getCurrentUser(): Promise<AuthUser | null> {
|
export async function getCurrentUser(): Promise<AuthSessionResponse> {
|
||||||
const response = await api.get<AuthSessionResponse>('/auth/me');
|
const response = await api.get<AuthSessionResponse>('/auth/me');
|
||||||
return response.data.user;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1044,4 +1081,28 @@ export async function revokeApiKey(keyId: number): Promise<void> {
|
|||||||
await api.delete(`/account/api-keys/${keyId}`);
|
await api.delete(`/account/api-keys/${keyId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cost estimate before executing a tool.
|
||||||
|
*/
|
||||||
|
export async function estimateCost(
|
||||||
|
tool: string,
|
||||||
|
fileSizeKb?: number,
|
||||||
|
estimatedTokens?: number
|
||||||
|
): Promise<CostEstimate> {
|
||||||
|
const response = await api.post<CostEstimate>('/account/estimate', {
|
||||||
|
tool,
|
||||||
|
file_size_kb: fileSizeKb,
|
||||||
|
estimated_tokens: estimatedTokens,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credit info including dynamic tools.
|
||||||
|
*/
|
||||||
|
export async function getCreditInfo(): Promise<CreditInfo & { dynamic_tools?: Record<string, unknown> }> {
|
||||||
|
const response = await api.get<CreditInfo & { dynamic_tools?: Record<string, unknown> }>('/account/credit-info');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -5,31 +5,43 @@ import {
|
|||||||
logoutUser,
|
logoutUser,
|
||||||
registerUser,
|
registerUser,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
|
type CreditSummary,
|
||||||
} from '@/services/api';
|
} from '@/services/api';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
credits: CreditSummary | null;
|
||||||
|
isNewAccount: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
refreshUser: () => Promise<AuthUser | null>;
|
refreshUser: () => Promise<AuthUser | null>;
|
||||||
login: (email: string, password: string) => Promise<AuthUser>;
|
login: (email: string, password: string) => Promise<AuthUser>;
|
||||||
register: (email: string, password: string) => Promise<AuthUser>;
|
register: (email: string, password: string) => Promise<AuthUser>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
setCredits: (credits: CreditSummary) => void;
|
||||||
|
clearNewAccount: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
credits: null,
|
||||||
|
isNewAccount: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
|
||||||
refreshUser: async () => {
|
refreshUser: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const data = await getCurrentUser();
|
||||||
set({ user, isLoading: false, initialized: true });
|
set({
|
||||||
return user;
|
user: data.user,
|
||||||
|
credits: data.credits ?? null,
|
||||||
|
isLoading: false,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return data.user;
|
||||||
} catch {
|
} catch {
|
||||||
set({ user: null, isLoading: false, initialized: true });
|
set({ user: null, credits: null, isLoading: false, initialized: true });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -37,9 +49,15 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const user = await loginUser(email, password);
|
const data = await loginUser(email, password);
|
||||||
set({ user, isLoading: false, initialized: true });
|
set({
|
||||||
return user;
|
user: data.user,
|
||||||
|
credits: data.credits ?? null,
|
||||||
|
isNewAccount: false,
|
||||||
|
isLoading: false,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return data.user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false, initialized: true });
|
set({ isLoading: false, initialized: true });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -49,9 +67,15 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
register: async (email: string, password: string) => {
|
register: async (email: string, password: string) => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const user = await registerUser(email, password);
|
const data = await registerUser(email, password);
|
||||||
set({ user, isLoading: false, initialized: true });
|
set({
|
||||||
return user;
|
user: data.user,
|
||||||
|
credits: data.credits ?? null,
|
||||||
|
isNewAccount: !!data.is_new_account,
|
||||||
|
isLoading: false,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return data.user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false, initialized: true });
|
set({ isLoading: false, initialized: true });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -62,10 +86,13 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
await logoutUser();
|
await logoutUser();
|
||||||
set({ user: null, isLoading: false, initialized: true });
|
set({ user: null, credits: null, isNewAccount: false, isLoading: false, initialized: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCredits: (credits: CreditSummary) => set({ credits }),
|
||||||
|
clearNewAccount: () => set({ isNewAccount: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
1412
package-lock.json
generated
1412
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@doist/todoist-ai": "^8.8.2",
|
||||||
"@microsoft/clarity": "^1.0.2"
|
"@microsoft/clarity": "^1.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user