diff --git a/backend/app/routes/account.py b/backend/app/routes/account.py index 7ffb689..eb46784 100644 --- a/backend/app/routes/account.py +++ b/backend/app/routes/account.py @@ -15,10 +15,17 @@ from app.services.policy_service import get_usage_summary_for_user from app.services.credit_config import ( get_all_tool_costs, get_credits_for_plan, + get_dynamic_tools_info, get_tool_credit_cost, CREDIT_WINDOW_DAYS, ) 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 ( is_stripe_configured, 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}, }, "tool_costs": get_all_tool_costs(), + "dynamic_tools": get_dynamic_tools_info(), }), 200 @@ -190,6 +198,7 @@ def claim_task_route(): Called after a guest signs up or logs in to record the previously processed task in their account and deduct credits. + Uses the quote engine, so welcome bonus is applied automatically. """ user_id = get_current_user_id() if user_id is None: @@ -216,15 +225,15 @@ def claim_task_route(): return jsonify({"error": "User not found."}), 404 plan = user.get("plan", "free") - cost = get_tool_credit_cost(tool) - # Deduct credits + # Use the quote engine (supports welcome bonus) try: - deduct_credits(user_id, plan, tool) - except ValueError: + quote = create_quote(user_id, plan, tool) + fulfill_quote(quote, user_id, plan) + except (QuoteError, ValueError) as exc: return jsonify({ - "error": "Insufficient credits to claim this file.", - "credits_required": cost, + "error": str(exc), + "credits_required": get_tool_credit_cost(tool), }), 429 # Record usage event so the task appears in history @@ -235,8 +244,38 @@ def claim_task_route(): task_id=task_id, event_type="accepted", api_key_id=None, - cost_points=cost, + cost_points=quote.charged_credits, + quoted_credits=quote.quoted_credits, ) 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 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 34cfd0b..b796ac2 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -13,6 +13,7 @@ from app.services.account_service import ( verify_and_consume_reset_token, update_user_password, ) +from app.services.credit_service import get_credit_summary from app.services.email_service import send_password_reset_email from app.utils.auth import ( get_current_user_id, @@ -62,7 +63,13 @@ def register_route(): return jsonify({"error": str(exc)}), 409 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"]) @@ -79,7 +86,12 @@ def login_route(): return jsonify({"error": "Invalid email or password."}), 401 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"]) @@ -103,7 +115,8 @@ def me_route(): logout_user_session() 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"]) diff --git a/backend/app/routes/compress.py b/backend/app/routes/compress.py index 7adc8f6..529bcff 100644 --- a/backend/app/routes/compress.py +++ b/backend/app/routes/compress.py @@ -1,4 +1,6 @@ """PDF compression routes.""" +import os + from flask import Blueprint, request, jsonify from app.extensions import limiter @@ -10,6 +12,7 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.quote_service import create_quote, QuoteError from app.utils.file_validator import FileValidationError from app.utils.sanitizer import generate_safe_path 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") 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( input_path, task_id, @@ -57,9 +66,10 @@ def compress_pdf_route(): quality, **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({ "task_id": task.id, "message": "Compression started. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), }), 202 diff --git a/backend/app/routes/compress_image.py b/backend/app/routes/compress_image.py index 3994d35..f8e057f 100644 --- a/backend/app/routes/compress_image.py +++ b/backend/app/routes/compress_image.py @@ -1,4 +1,6 @@ """Image compression routes.""" +import os + from flask import Blueprint, request, jsonify from app.extensions import limiter @@ -10,6 +12,7 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.quote_service import create_quote, QuoteError from app.utils.file_validator import FileValidationError from app.utils.sanitizer import generate_safe_path 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") 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( input_path, task_id, @@ -64,9 +73,10 @@ def compress_image_route(): quality, **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({ "task_id": task.id, "message": "Image compression started. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), }), 202 diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index a3b0ec2..f7439a8 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -7,6 +7,7 @@ from app.services.policy_service import ( resolve_web_actor, FREE_PLAN, ) +from app.services.credit_config import get_dynamic_tools_info config_bp = Blueprint("config", __name__) @@ -24,6 +25,7 @@ def get_config(): payload: dict = { "file_limits_mb": file_limits_mb, "max_upload_mb": max(file_limits_mb.values()), + "dynamic_tools": get_dynamic_tools_info(), } if actor.user_id is not None: diff --git a/backend/app/routes/ocr.py b/backend/app/routes/ocr.py index 87deb74..1fb8e12 100644 --- a/backend/app/routes/ocr.py +++ b/backend/app/routes/ocr.py @@ -1,4 +1,6 @@ """OCR routes — extract text from images and PDFs.""" +import os + from flask import Blueprint, request, jsonify, current_app from app.extensions import limiter @@ -10,6 +12,7 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.quote_service import create_quote, QuoteError from app.services.ocr_service import SUPPORTED_LANGUAGES from app.utils.file_validator import FileValidationError 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") 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( input_path, task_id, original_filename, lang, **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({ "task_id": task.id, "message": "OCR started. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), }), 202 @@ -116,15 +126,22 @@ def ocr_pdf_route(): task_id, input_path = generate_safe_path(ext, folder_type="upload") 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( input_path, task_id, original_filename, lang, **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({ "task_id": task.id, "message": "OCR started. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), }), 202 diff --git a/backend/app/routes/pdf_ai.py b/backend/app/routes/pdf_ai.py index b084148..917079d 100644 --- a/backend/app/routes/pdf_ai.py +++ b/backend/app/routes/pdf_ai.py @@ -1,5 +1,7 @@ """PDF AI tool routes — Chat, Summarize, Translate, Table Extract.""" +import os + from flask import Blueprint, request, jsonify from app.extensions import limiter @@ -11,6 +13,7 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.quote_service import create_quote, QuoteError from app.services.translation_guardrails import ( check_page_admission, TranslationAdmissionError, @@ -66,6 +69,13 @@ def chat_pdf_route(): task_id, input_path = generate_safe_path(ext, folder_type="upload") 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( input_path, task_id, @@ -73,12 +83,13 @@ def chat_pdf_route(): question, **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( { "task_id": task.id, "message": "Processing your question. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), } ), 202 @@ -122,6 +133,13 @@ def summarize_pdf_route(): task_id, input_path = generate_safe_path(ext, folder_type="upload") 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( input_path, task_id, @@ -129,12 +147,13 @@ def summarize_pdf_route(): length, **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( { "task_id": task.id, "message": "Summarizing document. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), } ), 202 @@ -185,6 +204,13 @@ def translate_pdf_route(): except TranslationAdmissionError as e: 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( input_path, task_id, @@ -193,12 +219,13 @@ def translate_pdf_route(): source_language, **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( { "task_id": task.id, "message": "Translating document. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), } ), 202 @@ -237,17 +264,25 @@ def extract_tables_route(): task_id, input_path = generate_safe_path(ext, folder_type="upload") 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( input_path, task_id, original_filename, **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( { "task_id": task.id, "message": "Extracting tables. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), } ), 202 diff --git a/backend/app/routes/removebg.py b/backend/app/routes/removebg.py index e9500cd..18aa77f 100644 --- a/backend/app/routes/removebg.py +++ b/backend/app/routes/removebg.py @@ -1,4 +1,6 @@ """Background removal route.""" +import os + from flask import Blueprint, request, jsonify, current_app from app.extensions import limiter @@ -10,6 +12,7 @@ from app.services.policy_service import ( resolve_web_actor, validate_actor_file, ) +from app.services.quote_service import create_quote, QuoteError from app.utils.file_validator import FileValidationError from app.utils.sanitizer import generate_safe_path 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") 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( input_path, task_id, original_filename, **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({ "task_id": task.id, "message": "Background removal started. Poll /api/tasks/{task_id}/status for progress.", + "quote": quote.to_dict(), }), 202 diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py index 9a1f7cd..6ed7b88 100644 --- a/backend/app/services/account_service.py +++ b/backend/app/services/account_service.py @@ -77,6 +77,7 @@ def _serialize_user(row: dict | None) -> dict | None: "role": _resolve_row_role(row), "is_allowlisted_admin": is_allowlisted_admin_email(row.get("email")), "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" ) + # 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): conn.executescript( @@ -366,6 +379,10 @@ def _init_sqlite_tables(conn): conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'") if not _column_exists(conn, "usage_events", "cost_points"): conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1") + 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: @@ -398,9 +415,9 @@ def create_user(email: str, password: str) -> dict: user_id = cursor.lastrowid 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() - 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,)) 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: with db_connection() as conn: 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() - 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,)) 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)) 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() - 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,)) 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)) 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() - 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,)) row = cursor.fetchone() @@ -884,6 +901,7 @@ def record_usage_event( event_type: str, api_key_id: int | None = None, cost_points: int = 1, + quoted_credits: int | None = None, ): if user_id is None: return @@ -893,17 +911,17 @@ def record_usage_event( """ INSERT INTO usage_events ( 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() else """ INSERT INTO usage_events ( 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( @@ -919,6 +937,7 @@ def record_usage_event( _utc_now(), get_current_period_month(), cost_points, + quoted_credits, ), ) @@ -982,9 +1001,9 @@ def get_user_by_email(email: str) -> dict | None: email = _normalize_email(email) with db_connection() as conn: 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() - 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,)) 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 (?, ?, ?, ?)" ) 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 diff --git a/backend/app/services/credit_config.py b/backend/app/services/credit_config.py index b24fca5..176997c 100644 --- a/backend/app/services/credit_config.py +++ b/backend/app/services/credit_config.py @@ -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, 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 -constants consumed by policy_service, credit_service, and the -frontend config endpoint. +constants consumed by policy_service, credit_service, quote_service, +and the frontend config endpoint. """ +from __future__ import annotations + +import math import os +from dataclasses import dataclass # ── Credit allocations per rolling 30-day window ──────────────── 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_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 ─────────────────────────────────────── # Keys match the `tool` parameter passed to record_usage_event / routes. TOOL_CREDIT_COSTS: dict[str, int] = { @@ -108,7 +207,11 @@ DEFAULT_CREDIT_COST = TIER_LIGHT 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) @@ -120,3 +223,29 @@ def get_credits_for_plan(plan: str) -> int: def get_all_tool_costs() -> dict[str, int]: """Return the full cost registry — used by the config API endpoint.""" 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 diff --git a/backend/app/services/credit_service.py b/backend/app/services/credit_service.py index aa2760f..b0f12e6 100644 --- a/backend/app/services/credit_service.py +++ b/backend/app/services/credit_service.py @@ -219,19 +219,26 @@ def deduct_credits(user_id: int, plan: str, tool: str) -> int: Raises ValueError if insufficient credits. """ 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: - # Ensure window is current window = _get_window(conn, user_id) if window is None or _is_window_expired(window.get("window_end_at", "")): - # get_or_create handles reset pass window = get_or_create_credit_window(user_id, plan) balance = window["credits_allocated"] - window["credits_used"] if balance < cost: raise ValueError( - f"Insufficient credits: {balance} remaining, {cost} required for {tool}." + f"Insufficient credits: {balance} remaining, {cost} required." ) sql = ( diff --git a/backend/app/services/policy_service.py b/backend/app/services/policy_service.py index b9ff9cd..4fc845e 100644 --- a/backend/app/services/policy_service.py +++ b/backend/app/services/policy_service.py @@ -29,6 +29,7 @@ from app.services.guest_budget_service import ( assert_guest_budget_available, 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 has_session_task_access, remember_task_access 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) -def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): - """Record one accepted usage event and deduct credits after task dispatch.""" +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. + + 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": remember_task_access(celery_task_id) - cost = get_tool_credit_cost(tool) + charged = 0 # Deduct credits from the rolling window (registered users only) if actor.user_id is not None and actor.source == "web": - try: - deduct_credits(actor.user_id, actor.plan, tool) - except ValueError: - # Balance check should have caught this in assert_quota_available. - # Log but don't block — the usage event is the source of truth. - import logging - logging.getLogger(__name__).warning( - "Credit deduction failed for user %d tool %s (insufficient balance at record time)", - actor.user_id, - tool, - ) + 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: + deduct_credits(actor.user_id, actor.plan, tool) + charged = cost + except ValueError: + import logging + logging.getLogger(__name__).warning( + "Credit deduction failed for user %d tool %s (insufficient balance at record time)", + actor.user_id, + tool, + ) + charged = cost elif actor.user_id is None and actor.source == "web": # Record guest demo usage record_guest_usage() + charged = get_tool_credit_cost(tool) record_usage_event( 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, event_type="accepted", api_key_id=actor.api_key_id, - cost_points=cost, + cost_points=charged, + quoted_credits=quote.quoted_credits if quote else None, ) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py new file mode 100644 index 0000000..33a7bfd --- /dev/null +++ b/backend/app/services/quote_service.py @@ -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) diff --git a/frontend/scripts/generate-seo-assets.mjs b/frontend/scripts/generate-seo-assets.mjs index c86a615..89af579 100644 --- a/frontend/scripts/generate-seo-assets.mjs +++ b/frontend/scripts/generate-seo-assets.mjs @@ -33,6 +33,7 @@ const staticPages = [ { path: '/privacy', changefreq: 'yearly', priority: '0.3' }, { path: '/terms', changefreq: 'yearly', priority: '0.3' }, { path: '/pricing', changefreq: 'monthly', priority: '0.7' }, + { path: '/pricing-transparency', changefreq: 'monthly', priority: '0.7' }, { path: '/blog', changefreq: 'weekly', priority: '0.6' }, { path: '/developers', changefreq: 'monthly', priority: '0.5' }, ]; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a57bf61..e43b9a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ const AccountPage = lazy(() => import('@/pages/AccountPage')); const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); const PricingPage = lazy(() => import('@/pages/PricingPage')); +const PricingTransparencyPage = lazy(() => import('@/pages/PricingTransparencyPage')); const BlogPage = lazy(() => import('@/pages/BlogPage')); const BlogPostPage = lazy(() => import('@/pages/BlogPostPage')); const DevelopersPage = lazy(() => import('@/pages/DevelopersPage')); @@ -115,6 +116,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index 11f56b0..f4caf72 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -116,6 +116,12 @@ export default function Footer() { > {t('common.pricing')} + + {t('common.pricingTransparency')} + state.user); + const credits = useAuthStore((state) => state.credits); const [langOpen, setLangOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const langRef = useRef(null); @@ -110,6 +111,12 @@ export default function Header() { > {user?.email || t('common.account')} + {user && credits && ( + + + {credits.credits_remaining} + + )} {/* Dark Mode Toggle */} @@ -193,9 +200,15 @@ export default function Header() { 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')} + {user?.email || t('common.account')} + {user && credits && ( + + + {credits.credits_remaining} + + )} s.user); + const [estimate, setEstimate] = useState(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 ( +
+
+ + + {t('costEstimate.cost')}: {estimate.quoted_credits} {t('costEstimate.credits')} + {estimate.welcome_bonus_applied && ( + + + {t('costEstimate.firstFree')} + + )} + +
+
+ {isAffordable ? ( + {t('costEstimate.remaining')}: {estimate.balance_after} + ) : ( + + + {t('costEstimate.insufficient')} + + )} +
+
+ ); +} diff --git a/frontend/src/components/shared/SignUpToDownloadModal.tsx b/frontend/src/components/shared/SignUpToDownloadModal.tsx index d1cec08..ab67ded 100644 --- a/frontend/src/components/shared/SignUpToDownloadModal.tsx +++ b/frontend/src/components/shared/SignUpToDownloadModal.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react'; 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 { claimTask } from '@/services/api'; @@ -19,6 +20,8 @@ export default function SignUpToDownloadModal({ }: SignUpToDownloadModalProps) { const { t } = useTranslation(); const { login, register } = useAuthStore(); + const isNewAccount = useAuthStore((state) => state.isNewAccount); + const clearNewAccount = useAuthStore((state) => state.clearNewAccount); const [mode, setMode] = useState<'register' | 'login'>('register'); 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: , + duration: 6000, + }); + clearNewAccount(); + } + onClose(); } catch (err) { setError(err instanceof Error ? err.message : t('account.loadFailed')); diff --git a/frontend/src/components/shared/ToolTemplate.tsx b/frontend/src/components/shared/ToolTemplate.tsx index 16fe662..270addf 100644 --- a/frontend/src/components/shared/ToolTemplate.tsx +++ b/frontend/src/components/shared/ToolTemplate.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { LucideIcon, AlertCircle, CheckCircle, Clock } from 'lucide-react'; import FileUploader from '@/components/shared/FileUploader'; +import CostEstimatePanel from '@/components/shared/CostEstimatePanel'; import ProgressBar from '@/components/shared/ProgressBar'; import DownloadButton from '@/components/shared/DownloadButton'; import AdSlot from '@/components/layout/AdSlot'; @@ -157,6 +158,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT ) || {}} /> + + {children && (
{children(templateProps)}
)} diff --git a/frontend/src/config/routes.ts b/frontend/src/config/routes.ts index 6600452..d9a30cd 100644 --- a/frontend/src/config/routes.ts +++ b/frontend/src/config/routes.ts @@ -26,6 +26,7 @@ const STATIC_PAGE_ROUTES = [ '/developers', '/tools', '/internal/admin', + '/pricing-transparency', ] as const; const SEO_PAGE_ROUTES = getAllSeoLandingPaths(); diff --git a/frontend/src/i18n/ar.json b/frontend/src/i18n/ar.json index f3d2966..8b9b08a 100644 --- a/frontend/src/i18n/ar.json +++ b/frontend/src/i18n/ar.json @@ -28,6 +28,7 @@ "lightMode": "الوضع الفاتح", "contact": "اتصل بنا", "pricing": "الأسعار", + "pricingTransparency": "كيف يعمل التسعير", "blog": "المدونة", "developers": "للمطورين", "send": "إرسال", @@ -299,9 +300,9 @@ }, "pricing": { "metaTitle": "الأسعار — Dociva", - "metaDescription": "قارن بين الخطة المجانية والاحترافية لـ Dociva. استخدم أكثر من 30 أداة مجانًا أو قم بالترقية للمعالجة غير المحدودة.", + "metaDescription": "قارن بين الخطة المجانية والاحترافية، وافهم كيف يعمل رصيد Dociva ولماذا قد تختلف تكلفة الأدوات بحسب عبء المعالجة.", "title": "الخطط والأسعار", - "subtitle": "ابدأ مجانًا وقم بالترقية عندما تحتاج المزيد.", + "subtitle": "ابدأ مجانًا، وراقب رصيدك بوضوح، وقم بالترقية فقط عندما تحتاج سعة معالجة أكبر.", "free": "مجاني", "pro": "احترافي", "freePrice": "$0", @@ -339,6 +340,15 @@ "emailSupport": "دعم عبر البريد الإلكتروني" }, "faqTitle": "الأسئلة الشائعة", + "faq1q": "هل الخطة المجانية مجانية فعلًا؟", + "faq1a": "نعم. يحصل الحساب المجاني على 50 رصيدًا كل 30 يومًا، وتبقى جميع الأدوات الأساسية متاحة بدون بطاقة ائتمان.", + "faq2q": "هل يمكنني إلغاء خطة Pro في أي وقت؟", + "faq2a": "نعم. يمكنك الإلغاء في أي وقت، وسيعود الحساب إلى الخطة المجانية عند نهاية فترة الفوترة الحالية.", + "faq3q": "ما وسائل الدفع المقبولة؟", + "faq3a": "نقبل بطاقات الائتمان والخصم الرئيسية عبر Stripe، وتتم معالجة المدفوعات بأمان دون كشف بيانات البطاقة لـ Dociva.", + "transparencyTitle": "اعرف بالضبط كيف يُحتسب الرصيد", + "transparencyBody": "اقرأ الشرح الكامل للفارق بين التسعير الثابت والمتغير، وعرض التكلفة قبل التنفيذ، ولماذا نربط السعر بعبء المعالجة لا بعدد معاملات جامد.", + "transparencyAction": "كيف يعمل التسعير", "faq": [ { "q": "هل الخطة المجانية مجانية فعلًا؟", @@ -354,7 +364,7 @@ } ], "trustTitle": "مصمم للفرق التي تحتاج سرعة ووضوحًا في النتائج", - "trustSubtitle": "المنصة نفسها تدعم الاستخدام السريع من المتصفح، والعمل المتكرر عبر الحساب، وتدفقات المستندات عبر API.", + "trustSubtitle": "المنصة نفسها تدعم المهام السريعة من المتصفح، والعمل المتكرر عبر الحساب، ونموذج رصيد يعكس عبء المعالجة الفعلي بصورة أوضح.", "trustFastTitle": "معالجة سريعة", "trustFastDesc": "تنفيذ المهام غير المتزامنة والعمال المحسّنون يساعدان على استمرار الأعمال الثقيلة دون تعطيل الواجهة.", "trustPrivateTitle": "الخصوصية افتراضيًا", @@ -362,6 +372,45 @@ "trustApiTitle": "جاهز للتكامل", "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": { "metaDescription": "استكشف بوابة مطوري Dociva، وتدفق API غير المتزامن، والنقاط الجاهزة لأتمتة المستندات.", "badge": "بوابة المطورين", @@ -1020,7 +1069,9 @@ "originalFile": "الملف الأصلي", "outputFile": "الملف الناتج", "statusCompleted": "مكتمل", - "statusFailed": "فشل" + "statusFailed": "فشل", + "welcomeTitle": "🎉 مرحباً بك في Dociva!", + "welcomeMessage": "لديك 50 عملة رصيد مجاني ومعاملتك الأولى علينا. ابدأ معالجة ملفاتك الآن!" }, "result": { "conversionComplete": "اكتمل التحويل!", @@ -1522,5 +1573,12 @@ {"q": "ما الفرق عن رمز QR؟", "a": "الباركود خطي (أحادي البعد) بسعة بيانات أقل. رموز QR ثنائية الأبعاد وتخزن معلومات أكثر."} ] } + }, + "costEstimate": { + "cost": "التكلفة", + "credits": "رصيد", + "firstFree": "الأولى مجاناً!", + "remaining": "المتبقي", + "insufficient": "رصيد غير كافٍ" } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 9f1b8f4..9973a19 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -28,6 +28,7 @@ "lightMode": "Light Mode", "contact": "Contact", "pricing": "Pricing", + "pricingTransparency": "How Pricing Works", "blog": "Blog", "developers": "Developers", "send": "Send", @@ -299,9 +300,9 @@ }, "pricing": { "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", - "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", "pro": "Pro", "freePrice": "$0", @@ -339,6 +340,15 @@ "emailSupport": "Email support" }, "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": [ { "q": "Is the free plan really free?", @@ -354,7 +364,7 @@ } ], "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", "trustFastDesc": "Async task handling and optimized workers keep heavy jobs moving without blocking the interface.", "trustPrivateTitle": "Private by default", @@ -362,6 +372,45 @@ "trustApiTitle": "Ready for integration", "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": { "metaDescription": "Explore the Dociva developer portal, async API flow, and production-ready endpoints for document automation.", "badge": "Developer Portal", @@ -1020,7 +1069,9 @@ "originalFile": "Original file", "outputFile": "Output file", "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": { "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."} ] } + }, + "costEstimate": { + "cost": "Cost", + "credits": "credits", + "firstFree": "First free!", + "remaining": "Remaining", + "insufficient": "Insufficient credits" } } diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 41d6108..460b957 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -28,6 +28,7 @@ "lightMode": "Mode clair", "contact": "Contact", "pricing": "Tarifs", + "pricingTransparency": "Comment fonctionne la tarification", "blog": "Blog", "developers": "Développeurs", "send": "Envoyer", @@ -299,9 +300,9 @@ }, "pricing": { "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", - "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", "pro": "Pro", "freePrice": "0€", @@ -339,6 +340,15 @@ "emailSupport": "Support par e-mail" }, "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": [ { "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é", - "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", "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", @@ -362,6 +372,45 @@ "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." }, + "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": { "metaDescription": "Explorez le portail développeur Dociva, le flux API asynchrone et les endpoints prêts pour l'automatisation documentaire.", "badge": "Portail développeur", @@ -1020,7 +1069,9 @@ "originalFile": "Fichier source", "outputFile": "Fichier de sortie", "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": { "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."} ] } + }, + "costEstimate": { + "cost": "Coût", + "credits": "crédits", + "firstFree": "Premier gratuit !", + "remaining": "Restant", + "insufficient": "Crédits insuffisants" } } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 16be042..c25ca9b 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -12,6 +12,7 @@ import { FolderClock, KeyRound, LogOut, + PartyPopper, ShieldCheck, Sparkles, Trash2, @@ -94,6 +95,21 @@ export default function AccountPage() { const login = useAuthStore((state) => state.login); const register = useAuthStore((state) => state.register); 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: , + duration: 6000, + }); + clearNewAccount(); + } + }, [isNewAccount, user, t, clearNewAccount]); const [mode, setMode] = useState('login'); const [email, setEmail] = useState(''); diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx index e71e953..f48ea58 100644 --- a/frontend/src/pages/PricingPage.tsx +++ b/frontend/src/pages/PricingPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import SEOHead from '@/components/seo/SEOHead'; 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 SocialProofStrip from '@/components/shared/SocialProofStrip'; import { getApiClient } from '@/services/api'; @@ -88,6 +88,32 @@ export default function PricingPage() {

{t('pages.pricing.subtitle', 'Start free with all tools. Upgrade when you need more power.')}

+ +
+
+
+
+ +
+
+

+ {t('pages.pricing.transparencyTitle')} +

+

+ {t('pages.pricing.transparencyBody')} +

+
+
+ + + {t('pages.pricing.transparencyAction')} + + +
+
diff --git a/frontend/src/pages/PricingTransparencyPage.tsx b/frontend/src/pages/PricingTransparencyPage.tsx new file mode 100644 index 0000000..a127bdd --- /dev/null +++ b/frontend/src/pages/PricingTransparencyPage.tsx @@ -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 ( + <> + + +
+
+ + {t('pages.pricingTransparency.badge')} + +

+ {t('pages.pricingTransparency.title')} +

+

+ {t('pages.pricingTransparency.subtitle')} +

+ +
+
+ +

+ {t('pages.pricingTransparency.creditsTitle')} +

+

+ {t('pages.pricingTransparency.creditsBody')} +

+
+ +
+ +

+ {t('pages.pricingTransparency.quoteTitle')} +

+

+ {t('pages.pricingTransparency.quoteBody')} +

+
+ +
+ +

+ {t('pages.pricingTransparency.fairnessTitle')} +

+

+ {t('pages.pricingTransparency.fairnessBody')} +

+
+
+
+ +
+
+
+ +

+ {t('pages.pricingTransparency.howTitle')} +

+
+

+ {t('pages.pricingTransparency.howBody')} +

+ +
+
+

+ {t('pages.pricingTransparency.fixedTitle')} +

+

+ {t('pages.pricingTransparency.fixedBody')} +

+
+
+

+ {t('pages.pricingTransparency.dynamicTitle')} +

+

+ {t('pages.pricingTransparency.dynamicBody')} +

+
+
+
+ +
+
+ +

+ {t('pages.pricingTransparency.noteTitle')} +

+
+

+ {t('pages.pricingTransparency.noteBody')} +

+
+

+ {t('pages.pricingTransparency.noOneToOneBody')} +

+
+
+
+ +
+

+ {t('pages.pricingTransparency.examplesTitle')} +

+
+
+

+ {t('pages.pricingTransparency.exampleLightTitle')} +

+

+ {t('pages.pricingTransparency.exampleLightBody')} +

+
+
+

+ {t('pages.pricingTransparency.exampleHeavyTitle')} +

+

+ {t('pages.pricingTransparency.exampleHeavyBody')} +

+
+
+

+ {t('pages.pricingTransparency.exampleAiTitle')} +

+

+ {t('pages.pricingTransparency.exampleAiBody')} +

+
+
+
+ +
+
+ +

+ {t('pages.pricingTransparency.futureTitle')} +

+
+

+ {t('pages.pricingTransparency.futureBody')} +

+ +
+
+

+ {t('pages.pricingTransparency.faq1q')} +

+

+ {t('pages.pricingTransparency.faq1a')} +

+
+
+

+ {t('pages.pricingTransparency.faq2q')} +

+

+ {t('pages.pricingTransparency.faq2a')} +

+
+
+

+ {t('pages.pricingTransparency.faq3q')} +

+

+ {t('pages.pricingTransparency.faq3a')} +

+
+
+ +
+ + {t('pages.pricingTransparency.pricingCta')} + + + + {t('pages.pricingTransparency.toolsCta')} + +
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3437ee6..bb1c3ff 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -280,17 +280,54 @@ export interface AuthUser { plan: string; role: 'user' | 'admin' | string; is_allowlisted_admin?: boolean; + welcome_bonus_available?: boolean; 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 { message: string; user: AuthUser; + credits?: CreditSummary; + is_new_account?: boolean; } interface AuthSessionResponse { authenticated: boolean; user: AuthUser | null; + credits?: CreditSummary; } interface HistoryResponse { @@ -454,21 +491,21 @@ export async function startTask(endpoint: string): Promise { } /** - * 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 { +export async function registerUser(email: string, password: string): Promise { const response = await api.post('/auth/register', { email, password }); 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 { +export async function loginUser(email: string, password: string): Promise { const response = await api.post('/auth/login', { email, password }); 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. */ -export async function getCurrentUser(): Promise { +export async function getCurrentUser(): Promise { const response = await api.get('/auth/me'); - return response.data.user; + return response.data; } /** @@ -1044,4 +1081,28 @@ export async function revokeApiKey(keyId: number): Promise { 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 { + const response = await api.post('/account/estimate', { + tool, + file_size_kb: fileSizeKb, + estimated_tokens: estimatedTokens, + }); + return response.data; +} + +/** + * Get credit info including dynamic tools. + */ +export async function getCreditInfo(): Promise }> { + const response = await api.get }>('/account/credit-info'); + return response.data; +} + export default api; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 7d8308b..975d8c3 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -5,31 +5,43 @@ import { logoutUser, registerUser, type AuthUser, + type CreditSummary, } from '@/services/api'; interface AuthState { user: AuthUser | null; + credits: CreditSummary | null; + isNewAccount: boolean; isLoading: boolean; initialized: boolean; refreshUser: () => Promise; login: (email: string, password: string) => Promise; register: (email: string, password: string) => Promise; logout: () => Promise; + setCredits: (credits: CreditSummary) => void; + clearNewAccount: () => void; } export const useAuthStore = create((set) => ({ user: null, + credits: null, + isNewAccount: false, isLoading: false, initialized: false, refreshUser: async () => { set({ isLoading: true }); try { - const user = await getCurrentUser(); - set({ user, isLoading: false, initialized: true }); - return user; + const data = await getCurrentUser(); + set({ + user: data.user, + credits: data.credits ?? null, + isLoading: false, + initialized: true, + }); + return data.user; } catch { - set({ user: null, isLoading: false, initialized: true }); + set({ user: null, credits: null, isLoading: false, initialized: true }); return null; } }, @@ -37,9 +49,15 @@ export const useAuthStore = create((set) => ({ login: async (email: string, password: string) => { set({ isLoading: true }); try { - const user = await loginUser(email, password); - set({ user, isLoading: false, initialized: true }); - return user; + const data = await loginUser(email, password); + set({ + user: data.user, + credits: data.credits ?? null, + isNewAccount: false, + isLoading: false, + initialized: true, + }); + return data.user; } catch (error) { set({ isLoading: false, initialized: true }); throw error; @@ -49,9 +67,15 @@ export const useAuthStore = create((set) => ({ register: async (email: string, password: string) => { set({ isLoading: true }); try { - const user = await registerUser(email, password); - set({ user, isLoading: false, initialized: true }); - return user; + const data = await registerUser(email, password); + set({ + user: data.user, + credits: data.credits ?? null, + isNewAccount: !!data.is_new_account, + isLoading: false, + initialized: true, + }); + return data.user; } catch (error) { set({ isLoading: false, initialized: true }); throw error; @@ -62,10 +86,13 @@ export const useAuthStore = create((set) => ({ set({ isLoading: true }); try { await logoutUser(); - set({ user: null, isLoading: false, initialized: true }); + set({ user: null, credits: null, isNewAccount: false, isLoading: false, initialized: true }); } catch (error) { set({ isLoading: false }); throw error; } }, + + setCredits: (credits: CreditSummary) => set({ credits }), + clearNewAccount: () => set({ isNewAccount: false }), })); diff --git a/package-lock.json b/package-lock.json index 692f85e..3bdd7a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,14 +5,1426 @@ "packages": { "": { "dependencies": { + "@doist/todoist-ai": "^8.8.2", "@microsoft/clarity": "^1.0.2" } }, + "node_modules/@doist/todoist-ai": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/@doist/todoist-ai/-/todoist-ai-8.8.2.tgz", + "integrity": "sha512-10Djt6cSo7rQoN/ywp84TvnCJFrboBM2rtzG2uGrvswDM4nKy34Bgag7csF3CjBym4zfVwslSdUtyaEKHiG4bQ==", + "license": "MIT", + "dependencies": { + "@doist/todoist-sdk": "8.0.0", + "@modelcontextprotocol/ext-apps": "1.2.2", + "date-fns": "4.1.0", + "dompurify": "3.3.3", + "dotenv": "17.3.1", + "express": "5.2.1", + "zod": "4.3.6" + }, + "bin": { + "todoist-ai": "dist/main.js", + "todoist-ai-http": "dist/main-http.js" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.0" + } + }, + "node_modules/@doist/todoist-sdk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@doist/todoist-sdk/-/todoist-sdk-8.0.0.tgz", + "integrity": "sha512-7YU9jpHCcQhrKUe+18b4a8ok8pc+ff2HoIuvkmxbQfP12dSEoYZ/pfUSHU2v/uGI9OdymLe6nVjCOZOHJRk96g==", + "license": "MIT", + "dependencies": { + "camelcase": "6.3.0", + "emoji-regex": "10.6.0", + "form-data": "4.0.5", + "ts-custom-error": "^3.2.0", + "undici": "^7.16.0", + "uuid": "11.1.0", + "zod": "4.3.6" + }, + "engines": { + "node": ">=20.18.1" + }, + "peerDependencies": { + "type-fest": "^4.12.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@microsoft/clarity": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@microsoft/clarity/-/clarity-1.0.2.tgz", "integrity": "sha512-9EZYROFpJxEGmQpHvUFqvD3ZJ7QQSqnibYSWmS+1xusoZfG1QQ1/Al9yVBBc11DWMbJrs1pe1hLT273it/skJg==", "license": "MIT" + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.2.2.tgz", + "integrity": "sha512-qMnhIKb8tyPesl+kZU76Xz9Bi9putCO+LcgvBJ00fDdIniiLZsnQbAeTKoq+sTiYH1rba2Fvj8NPAFxij+gyxw==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index d3ecad9..fbaa00f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@doist/todoist-ai": "^8.8.2", "@microsoft/clarity": "^1.0.2" } }