Преглед изворни кода

feat(sponsor-prompt): in-app toast at earned milestones

      ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
      sponsor conversion at 0.08% — roughly an order of magnitude under
      industry-benchmark for OSS with visible CTA. The Settings banner from
      0d4b9d4e gives passive every-visit visibility on one page; this adds
      opt-out-able active visibility at moments where the user has just
      earned something with Bambuddy.

      Five trigger families with a 14-day cross-family cooldown: prints
      (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
      energy), archives (50/250/1000), anniversary (1 year), version-update
      (re-armable on each major bump). New sponsor_toast_state table with
      nullable user_id so auth-disabled installs get the same trigger logic
      through one code path (NULL-keyed install-default row).
maziggy пре 2 месеци
родитељ
комит
e761e09297

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 55 - 0
backend/app/api/routes/sponsor_prompt.py

@@ -0,0 +1,55 @@
+"""API routes for the in-app sponsor toast."""
+
+import logging
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.schemas.sponsor_prompt import (
+    SponsorPromptCheckResponse,
+    SponsorPromptDismissRequest,
+)
+from backend.app.services import sponsor_prompt as service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/sponsor-prompt", tags=["sponsor-prompt"])
+
+
+def _user_id(current_user: User | None) -> int | None:
+    return current_user.id if current_user is not None else None
+
+
+@router.get("/check", response_model=SponsorPromptCheckResponse)
+async def check_sponsor_prompt(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Return the next eligible sponsor-toast trigger, or `{show: false}`."""
+    trigger = await service.evaluate(db, _user_id(current_user))
+    await db.commit()
+    if trigger is None:
+        return SponsorPromptCheckResponse(show=False)
+    return SponsorPromptCheckResponse(
+        show=True,
+        milestone=trigger.milestone,
+        family=trigger.family,
+        threshold=trigger.threshold,
+        payload=trigger.payload,
+    )
+
+
+@router.post("/dismiss", status_code=status.HTTP_204_NO_CONTENT)
+async def dismiss_sponsor_prompt(
+    data: SponsorPromptDismissRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Anchor the 14-day cooldown and record the milestone as shown."""
+    await service.dismiss(db, _user_id(current_user), data.milestone)
+    await db.commit()
+    return None

+ 2 - 0
backend/app/main.py

@@ -57,6 +57,7 @@ from backend.app.api.routes import (
     slice_jobs,
     slicer_presets,
     smart_plugs,
+    sponsor_prompt,
     spoolbuddy,
     spoolman,
     spoolman_inventory,
@@ -6525,6 +6526,7 @@ app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
+app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -25,6 +25,7 @@ from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
@@ -75,6 +76,7 @@ __all__ = [
     "SpoolUsageHistory",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
+    "SponsorToastState",
     "UserEmailPreference",
     "UserOTPCode",
     "UserTOTP",

+ 39 - 0
backend/app/models/sponsor_toast_state.py

@@ -0,0 +1,39 @@
+"""Per-user (or install-default) state for the sponsor-prompt toast.
+
+A single row stores which sponsor-toast milestones have already fired for a
+given user, when the most recent toast was shown (for the 14-day cooldown),
+and the app version last seen so we can fire the "version-update" trigger
+exactly once per major bump.
+
+``user_id`` is nullable: in auth-disabled installs (no user concept), the
+service stores everything against a single NULL-keyed row.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SponsorToastState(Base):
+    __tablename__ = "sponsor_toast_state"
+    __table_args__ = (UniqueConstraint("user_id", name="uq_sponsor_toast_state_user_id"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int | None] = mapped_column(
+        Integer,
+        ForeignKey("users.id", ondelete="CASCADE"),
+        nullable=True,
+        index=True,
+    )
+    last_shown_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    # JSON-serialised list[str] of milestone keys already fired (e.g. ["prints-100", "cost-100"]).
+    # Stored as Text for SQLite/Postgres uniformity; the service serialises with json.dumps.
+    milestones_seen: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+    last_seen_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 23 - 0
backend/app/schemas/sponsor_prompt.py

@@ -0,0 +1,23 @@
+"""Pydantic schemas for the sponsor-prompt API."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class SponsorPromptCheckResponse(BaseModel):
+    """Result of GET /sponsor-prompt/check."""
+
+    show: bool
+    milestone: str | None = None
+    family: str | None = None
+    threshold: int | None = None
+    payload: dict[str, Any] = Field(default_factory=dict)
+
+
+class SponsorPromptDismissRequest(BaseModel):
+    """Body of POST /sponsor-prompt/dismiss."""
+
+    milestone: str

+ 256 - 0
backend/app/services/sponsor_prompt.py

@@ -0,0 +1,256 @@
+"""Sponsor-prompt trigger evaluator and dismiss handler.
+
+Drives the in-app "support keeps Bambuddy independent" toast. Trigger families
+fire at milestones the user has earned (prints, archives, filament cost,
+anniversary) plus a soft version-update nudge after a major upgrade.
+
+A 14-day cooldown applies across ALL families: if any toast fired in the last
+14 days, no new toast fires. Each individual milestone is shown at most once
+per user (or once per install in auth-disabled mode); version-update is the
+exception — it re-arms every time the running version is newer than the one
+last acknowledged.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.sponsor_toast_state import SponsorToastState
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+COOLDOWN_DAYS = 14
+
+PRINT_MILESTONES = (100, 500, 1000, 2500, 5000)
+COST_MILESTONES = (100, 500, 1000)
+ARCHIVE_MILESTONES = (50, 250, 1000)
+ANNIVERSARY_YEARS = 1
+
+
+@dataclass
+class Trigger:
+    """Evaluated trigger result returned to the frontend."""
+
+    milestone: str  # e.g. "prints-500", "anniversary-1", "version-update"
+    family: str  # "prints" | "cost" | "archives" | "anniversary" | "version-update"
+    threshold: int | None = None
+    payload: dict[str, Any] = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------------------
+# State helpers
+# ---------------------------------------------------------------------------
+
+
+async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState:
+    """Fetch the state row for this user (or the install-default NULL row).
+
+    Creates the row lazily on first access so the migration doesn't need to
+    seed anything.
+    """
+    if user_id is None:
+        stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None))
+    else:
+        stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id)
+    result = await db.execute(stmt)
+    state = result.scalar_one_or_none()
+    if state is None:
+        state = SponsorToastState(user_id=user_id, milestones_seen="[]")
+        db.add(state)
+        await db.flush()
+    return state
+
+
+def _within_cooldown(state: SponsorToastState) -> bool:
+    if state.last_shown_at is None:
+        return False
+    cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS)
+    last = state.last_shown_at
+    if last.tzinfo is None:
+        last = last.replace(tzinfo=timezone.utc)
+    return last >= cutoff
+
+
+def _seen_milestones(state: SponsorToastState) -> set[str]:
+    try:
+        raw = json.loads(state.milestones_seen or "[]")
+        return set(raw) if isinstance(raw, list) else set()
+    except (json.JSONDecodeError, TypeError):
+        logger.warning(
+            "sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting",
+            state.user_id,
+        )
+        return set()
+
+
+# ---------------------------------------------------------------------------
+# Per-family checks
+# ---------------------------------------------------------------------------
+
+
+def _user_filter(column, user_id: int | None):
+    return column.is_(None) if user_id is None else column == user_id
+
+
+async def _check_anniversary(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    milestone = f"anniversary-{ANNIVERSARY_YEARS}"
+    if milestone in seen:
+        return None
+    if user_id is None:
+        # Install-anchor = earliest users.created_at (the first admin row).
+        result = await db.execute(select(func.min(User.created_at)))
+        anchor = result.scalar()
+    else:
+        result = await db.execute(select(User.created_at).where(User.id == user_id))
+        anchor = result.scalar()
+    if anchor is None:
+        return None
+    if anchor.tzinfo is None:
+        anchor = anchor.replace(tzinfo=timezone.utc)
+    if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS):
+        return None
+    return Trigger(milestone=milestone, family="anniversary")
+
+
+async def _check_prints(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = (
+        select(func.count())
+        .select_from(PrintLogEntry)
+        .where(
+            PrintLogEntry.status == "completed",
+            _user_filter(PrintLogEntry.created_by_id, user_id),
+        )
+    )
+    completed = (await db.execute(stmt)).scalar() or 0
+    # Pick the LARGEST milestone the user has crossed but not yet seen.
+    for threshold in sorted(PRINT_MILESTONES, reverse=True):
+        key = f"prints-{threshold}"
+        if completed >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="prints",
+                threshold=threshold,
+                payload={"count": completed},
+            )
+    return None
+
+
+async def _check_archives(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id))
+    archived = (await db.execute(stmt)).scalar() or 0
+    for threshold in sorted(ARCHIVE_MILESTONES, reverse=True):
+        key = f"archives-{threshold}"
+        if archived >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="archives",
+                threshold=threshold,
+                payload={"count": archived},
+            )
+    return None
+
+
+async def _check_cost(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = (
+        select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0))
+        .select_from(PrintLogEntry)
+        .where(_user_filter(PrintLogEntry.created_by_id, user_id))
+    )
+    total = float((await db.execute(stmt)).scalar() or 0)
+    for threshold in sorted(COST_MILESTONES, reverse=True):
+        key = f"cost-{threshold}"
+        if total >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="cost",
+                threshold=threshold,
+                payload={"total": round(total, 2)},
+            )
+    return None
+
+
+async def _check_version_update(
+    _db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState
+) -> Trigger | None:
+    # version-update is NOT in milestones_seen — it has its own state column
+    # so it can re-fire on each major bump.
+    if not APP_VERSION:
+        return None
+    last = state.last_seen_version
+    if last is None:
+        # First-ever read; treat as already-acknowledged so we don't toast
+        # immediately on a brand-new install. Persist current version silently.
+        state.last_seen_version = APP_VERSION
+        return None
+    if last == APP_VERSION:
+        return None
+    return Trigger(
+        milestone="version-update",
+        family="version-update",
+        payload={"from": last, "to": APP_VERSION},
+    )
+
+
+# Priority order: most emotional / earned first; version-update is the soft fallback.
+_CHECKS = (
+    _check_anniversary,
+    _check_prints,
+    _check_archives,
+    _check_cost,
+    _check_version_update,
+)
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None:
+    """Return the next eligible sponsor-toast trigger, or None."""
+    state = await _get_or_create_state(db, user_id)
+    if _within_cooldown(state):
+        return None
+    seen = _seen_milestones(state)
+    for check in _CHECKS:
+        trigger = await check(db, user_id, seen, state)
+        if trigger is not None:
+            return trigger
+    # No triggers eligible — still commit any in-progress state changes
+    # (e.g. version-update's first-touch persistence).
+    await db.flush()
+    return None
+
+
+async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None:
+    """Mark a milestone as shown (sets cooldown anchor + records seen)."""
+    state = await _get_or_create_state(db, user_id)
+    if milestone == "version-update":
+        # Re-armable: just update last_seen_version, don't add to seen-list.
+        state.last_seen_version = APP_VERSION
+    else:
+        seen = _seen_milestones(state)
+        if milestone not in seen:
+            seen.add(milestone)
+            state.milestones_seen = json.dumps(sorted(seen))
+    state.last_shown_at = datetime.now(timezone.utc)
+    await db.flush()

+ 1 - 0
backend/tests/conftest.py

@@ -139,6 +139,7 @@ async def test_engine():
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,  # noqa: F401
+        sponsor_toast_state,  # noqa: F401
         spool,
         spool_assignment,
         spool_catalog,

+ 48 - 0
backend/tests/integration/test_sponsor_prompt_api.py

@@ -0,0 +1,48 @@
+"""Integration tests for /sponsor-prompt routes."""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSponsorPromptAPI:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_returns_show_false_for_empty_install(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["show"] is False
+        assert body.get("milestone") is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dismiss_requires_milestone(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/sponsor-prompt/dismiss", json={})
+        # Pydantic missing-field → 422.
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dismiss_returns_204(self, async_client: AsyncClient):
+        response = await async_client.post(
+            "/api/v1/sponsor-prompt/dismiss",
+            json={"milestone": "version-update"},
+        )
+        assert response.status_code == 204
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_then_dismiss_then_recheck_is_silent(self, async_client: AsyncClient):
+        """End-to-end: even if no trigger is currently eligible, dismissing
+        anchors the cooldown, so a subsequent check stays {show: false}."""
+        first = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert first.json()["show"] is False
+        dismiss = await async_client.post(
+            "/api/v1/sponsor-prompt/dismiss",
+            json={"milestone": "version-update"},
+        )
+        assert dismiss.status_code == 204
+        second = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert second.json()["show"] is False

+ 327 - 0
backend/tests/unit/test_sponsor_prompt_service.py

@@ -0,0 +1,327 @@
+"""Unit tests for the sponsor-prompt trigger evaluator."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.sponsor_toast_state import SponsorToastState
+from backend.app.models.user import User
+from backend.app.services import sponsor_prompt as service
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+async def _make_user(db: AsyncSession, *, username: str = "alice", created_days_ago: int = 0) -> User:
+    user = User(username=username, role="admin")
+    db.add(user)
+    await db.flush()
+    if created_days_ago:
+        user.created_at = datetime.now(timezone.utc) - timedelta(days=created_days_ago)
+        await db.flush()
+    return user
+
+
+async def _add_completed_prints(db: AsyncSession, *, user_id: int | None, count: int, cost_each: float = 0.0) -> None:
+    for _ in range(count):
+        db.add(
+            PrintLogEntry(
+                status="completed",
+                created_by_id=user_id,
+                cost=cost_each if cost_each else None,
+            )
+        )
+    await db.flush()
+
+
+async def _add_archives(db: AsyncSession, *, user_id: int | None, count: int) -> None:
+    for i in range(count):
+        db.add(
+            PrintArchive(
+                filename=f"archive-{i}.zip",
+                file_path=f"/tmp/archive-{i}.zip",
+                file_size=1024,
+                created_by_id=user_id,
+            )
+        )
+    await db.flush()
+
+
+# ---------------------------------------------------------------------------
+# Empty / no-eligibility cases
+# ---------------------------------------------------------------------------
+
+
+class TestEmptyState:
+    @pytest.mark.asyncio
+    async def test_evaluate_returns_none_for_fresh_user(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is None
+
+    @pytest.mark.asyncio
+    async def test_state_row_is_created_lazily(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await service.evaluate(db_session, user.id)
+        from sqlalchemy import select
+
+        row = (
+            await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
+        ).scalar_one_or_none()
+        assert row is not None
+        assert row.milestones_seen == "[]"
+
+
+# ---------------------------------------------------------------------------
+# Cooldown
+# ---------------------------------------------------------------------------
+
+
+class TestCooldown:
+    @pytest.mark.asyncio
+    async def test_no_toast_within_14d_window(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=200)
+        # Pre-populate state with a recent last_shown_at
+        state = SponsorToastState(
+            user_id=user.id,
+            last_shown_at=datetime.now(timezone.utc) - timedelta(days=3),
+        )
+        db_session.add(state)
+        await db_session.flush()
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is None
+
+    @pytest.mark.asyncio
+    async def test_toast_eligible_after_14d_window(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=200)
+        state = SponsorToastState(
+            user_id=user.id,
+            last_shown_at=datetime.now(timezone.utc) - timedelta(days=15),
+        )
+        db_session.add(state)
+        await db_session.flush()
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.family == "prints"
+
+
+# ---------------------------------------------------------------------------
+# Per-family triggers
+# ---------------------------------------------------------------------------
+
+
+class TestPrintMilestones:
+    @pytest.mark.asyncio
+    async def test_fires_at_100(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=100)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.milestone == "prints-100"
+        assert trigger.threshold == 100
+
+    @pytest.mark.asyncio
+    async def test_picks_highest_unseen_milestone(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=600)
+        trigger = await service.evaluate(db_session, user.id)
+        # 500 is the highest crossed milestone (1000 not reached).
+        assert trigger is not None
+        assert trigger.milestone == "prints-500"
+
+    @pytest.mark.asyncio
+    async def test_skips_already_seen(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=600)
+        # Mark prints-500 as already seen — but NOT prints-100.
+        # Service should fall through to the next-largest unseen, which is prints-100.
+        state = SponsorToastState(
+            user_id=user.id,
+            milestones_seen=json.dumps(["prints-500"]),
+        )
+        db_session.add(state)
+        await db_session.flush()
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.milestone == "prints-100"
+
+    @pytest.mark.asyncio
+    async def test_failed_prints_dont_count(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=50)
+        for _ in range(60):
+            db_session.add(PrintLogEntry(status="failed", created_by_id=user.id))
+        await db_session.flush()
+        trigger = await service.evaluate(db_session, user.id)
+        # Only 50 completed → below 100 threshold → no print trigger.
+        # Anniversary not reached either; no other counter populated.
+        assert trigger is None
+
+
+class TestArchiveMilestones:
+    @pytest.mark.asyncio
+    async def test_fires_at_50(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_archives(db_session, user_id=user.id, count=50)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.milestone == "archives-50"
+
+
+class TestCostMilestones:
+    @pytest.mark.asyncio
+    async def test_fires_when_cost_sum_crosses_100(self, db_session: AsyncSession):
+        # Prints with cost = ~3.5 each, 30 prints → 105.
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=30, cost_each=3.5)
+        # 30 < 100 prints, so prints-100 not eligible. cost = 105 ≥ 100 → fires.
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.family == "cost"
+        assert trigger.milestone == "cost-100"
+
+
+class TestAnniversary:
+    @pytest.mark.asyncio
+    async def test_fires_after_1_year(self, db_session: AsyncSession):
+        user = await _make_user(db_session, created_days_ago=370)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.milestone == "anniversary-1"
+        assert trigger.family == "anniversary"
+
+    @pytest.mark.asyncio
+    async def test_does_not_fire_before_1_year(self, db_session: AsyncSession):
+        user = await _make_user(db_session, created_days_ago=300)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is None
+
+
+class TestVersionUpdate:
+    @pytest.mark.asyncio
+    async def test_first_read_silently_anchors(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        with patch.object(service, "APP_VERSION", "0.3.0"):
+            trigger = await service.evaluate(db_session, user.id)
+        assert trigger is None
+        from sqlalchemy import select
+
+        state = (
+            await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
+        ).scalar_one()
+        assert state.last_seen_version == "0.3.0"
+
+    @pytest.mark.asyncio
+    async def test_fires_on_version_bump(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0")
+        db_session.add(state)
+        await db_session.flush()
+        with patch.object(service, "APP_VERSION", "0.3.0"):
+            trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.milestone == "version-update"
+        assert trigger.payload == {"from": "0.2.0", "to": "0.3.0"}
+
+
+# ---------------------------------------------------------------------------
+# Priority order
+# ---------------------------------------------------------------------------
+
+
+class TestPriorityOrder:
+    @pytest.mark.asyncio
+    async def test_anniversary_beats_prints(self, db_session: AsyncSession):
+        # User old enough for anniversary AND with 100+ prints.
+        user = await _make_user(db_session, created_days_ago=400)
+        await _add_completed_prints(db_session, user_id=user.id, count=200)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.family == "anniversary"
+
+    @pytest.mark.asyncio
+    async def test_prints_beats_archives(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=200)
+        await _add_archives(db_session, user_id=user.id, count=100)
+        trigger = await service.evaluate(db_session, user.id)
+        assert trigger is not None
+        assert trigger.family == "prints"
+
+
+# ---------------------------------------------------------------------------
+# Dismiss
+# ---------------------------------------------------------------------------
+
+
+class TestDismiss:
+    @pytest.mark.asyncio
+    async def test_dismiss_adds_to_seen_and_anchors_cooldown(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        await _add_completed_prints(db_session, user_id=user.id, count=100)
+        await service.evaluate(db_session, user.id)
+        await service.dismiss(db_session, user.id, "prints-100")
+        from sqlalchemy import select
+
+        state = (
+            await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
+        ).scalar_one()
+        assert "prints-100" in json.loads(state.milestones_seen)
+        assert state.last_shown_at is not None
+        # Re-evaluation must now return None (cooldown).
+        next_trigger = await service.evaluate(db_session, user.id)
+        assert next_trigger is None
+
+    @pytest.mark.asyncio
+    async def test_version_update_dismiss_updates_version_not_seen_list(self, db_session: AsyncSession):
+        user = await _make_user(db_session)
+        state = SponsorToastState(user_id=user.id, last_seen_version="0.2.0")
+        db_session.add(state)
+        await db_session.flush()
+        with patch.object(service, "APP_VERSION", "0.3.0"):
+            await service.dismiss(db_session, user.id, "version-update")
+        from sqlalchemy import select
+
+        state = (
+            await db_session.execute(select(SponsorToastState).where(SponsorToastState.user_id == user.id))
+        ).scalar_one()
+        assert state.last_seen_version == "0.3.0"
+        assert json.loads(state.milestones_seen) == []
+
+
+# ---------------------------------------------------------------------------
+# Auth-disabled (user_id = None) — NULL-keyed install-default row
+# ---------------------------------------------------------------------------
+
+
+class TestAuthDisabledMode:
+    @pytest.mark.asyncio
+    async def test_uses_install_anchor_for_anniversary(self, db_session: AsyncSession):
+        # In auth-disabled mode, anniversary anchor = MIN(users.created_at).
+        # Seed a user from >1 year ago.
+        await _make_user(db_session, username="root", created_days_ago=400)
+        # Prints written without created_by_id.
+        await _add_completed_prints(db_session, user_id=None, count=10)
+        trigger = await service.evaluate(db_session, None)
+        assert trigger is not None
+        assert trigger.family == "anniversary"
+
+    @pytest.mark.asyncio
+    async def test_null_keyed_counters_isolated_from_per_user(self, db_session: AsyncSession):
+        # A user-attributed prints set should NOT show up in the install-default count.
+        user = await _make_user(db_session, username="alice")
+        await _add_completed_prints(db_session, user_id=user.id, count=200)
+        # NULL-keyed install has zero prints.
+        trigger = await service.evaluate(db_session, None)
+        # No anniversary either (user only just created).
+        assert trigger is None

+ 17 - 0
frontend/src/api/client.ts

@@ -7141,3 +7141,20 @@ export const bugReportApi = {
       method: 'POST',
     }),
 };
+
+export interface SponsorPromptCheckResponse {
+  show: boolean;
+  milestone?: string;
+  family?: 'prints' | 'cost' | 'archives' | 'anniversary' | 'version-update';
+  threshold?: number;
+  payload?: Record<string, unknown>;
+}
+
+export const sponsorPromptApi = {
+  check: () => request<SponsorPromptCheckResponse>('/sponsor-prompt/check'),
+  dismiss: (milestone: string) =>
+    request<void>('/sponsor-prompt/dismiss', {
+      method: 'POST',
+      body: JSON.stringify({ milestone }),
+    }),
+};

+ 4 - 0
frontend/src/components/Layout.tsx

@@ -11,6 +11,7 @@ import { api, supportApi, pendingUploadsApi, type Permission } from '../api/clie
 import { getIconByName } from './IconPicker';
 import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
+import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
@@ -112,6 +113,9 @@ export function Layout() {
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
+  // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
+  useSponsorPrompt(settings?.currency ?? 'EUR');
+
   // Fetch default sidebar order via a public endpoint (no settings:read needed)
   const { data: defaultSidebarData } = useQuery({
     queryKey: ['default-sidebar-order'],

+ 43 - 12
frontend/src/contexts/ToastContext.tsx

@@ -6,13 +6,25 @@ import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
-type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
+interface ToastAction {
+  label: string;
+  href: string;
+  onClick?: () => void;
+}
+
+type ShowPersistentToast = (
+  id: string,
+  message: string,
+  type?: ToastType,
+  options?: { action?: ToastAction },
+) => void;
 
 interface Toast {
   id: string;
   message: string;
   type: ToastType;
   persistent?: boolean;
+  action?: ToastAction;
   dispatchData?: DispatchToastData;
 }
 
@@ -120,17 +132,22 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     timeoutRefs.current.set(id, timeout);
   }, []);
 
-  const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => {
-    if (!isMountedRef.current) return;
-    setToasts((prev) => {
-      // Update existing toast if same id, otherwise add new one
-      const exists = prev.find((t) => t.id === id);
-      if (exists) {
-        return prev.map((t) => (t.id === id ? { ...t, message, type, persistent: true } : t));
-      }
-      return [...prev, { id, message, type, persistent: true }];
-    });
-  }, []);
+  const showPersistentToast = useCallback(
+    (id: string, message: string, type: ToastType = 'info', options?: { action?: ToastAction }) => {
+      if (!isMountedRef.current) return;
+      setToasts((prev) => {
+        // Update existing toast if same id, otherwise add new one
+        const exists = prev.find((t) => t.id === id);
+        if (exists) {
+          return prev.map((t) =>
+            t.id === id ? { ...t, message, type, persistent: true, action: options?.action } : t,
+          );
+        }
+        return [...prev, { id, message, type, persistent: true, action: options?.action }];
+      });
+    },
+    [],
+  );
 
   const dismissToast = useCallback((id: string) => {
     if (!isMountedRef.current) return;
@@ -632,6 +649,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
               <>
                 {icons[toast.type]}
                 <span className="text-white text-sm">{toast.message}</span>
+                {toast.action && (
+                  <a
+                    href={toast.action.href}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    onClick={() => {
+                      toast.action?.onClick?.();
+                      dismissToast(toast.id);
+                    }}
+                    className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
+                  >
+                    {toast.action.label}
+                  </a>
+                )}
                 <button
                   onClick={() => dismissToast(toast.id)}
                   className="ml-2 text-bambu-gray hover:text-white transition-colors"

+ 90 - 0
frontend/src/hooks/useSponsorPrompt.ts

@@ -0,0 +1,90 @@
+/**
+ * Sponsor-prompt toast hook. Fires once per browser session: after auth
+ * resolves, hits /sponsor-prompt/check; if a trigger is eligible, displays a
+ * persistent toast with a "View supporters" CTA that links to the public
+ * sponsors page with a Matomo-trackable `?from=app-toast-{milestone}` param.
+ *
+ * The 14-day cooldown + already-seen-milestone deduplication is owned by the
+ * backend service — the hook just trusts the check endpoint's verdict.
+ */
+import { useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { sponsorPromptApi, type SponsorPromptCheckResponse } from '../api/client';
+import { getCurrencySymbol } from '../utils/currency';
+
+const TOAST_ID = 'sponsor-prompt';
+const SESSION_SHOWN_KEY = 'sponsorPromptShown';
+
+function _num(v: unknown, fallback = 0): number {
+  return typeof v === 'number' ? v : fallback;
+}
+
+function _str(v: unknown, fallback = ''): string {
+  return typeof v === 'string' ? v : fallback;
+}
+
+function buildMessage(
+  t: ReturnType<typeof useTranslation>['t'],
+  trigger: SponsorPromptCheckResponse,
+  currencyCode: string,
+): string | null {
+  const family = trigger.family;
+  const payload = trigger.payload ?? {};
+  const threshold = trigger.threshold ?? 0;
+  switch (family) {
+    case 'prints':
+      return t('sponsors.toastPrints', { count: _num(payload.count, threshold) });
+    case 'archives':
+      return t('sponsors.toastArchives', { count: _num(payload.count, threshold) });
+    case 'cost': {
+      const total = _num(payload.total, threshold);
+      const symbol = getCurrencySymbol(currencyCode);
+      return t('sponsors.toastCost', { total: `${symbol}${total}` });
+    }
+    case 'anniversary':
+      return t('sponsors.toastAnniversary');
+    case 'version-update':
+      return t('sponsors.toastVersionUpdate', { version: _str(payload.to) });
+    default:
+      return null;
+  }
+}
+
+export function useSponsorPrompt(currencyCode = 'EUR') {
+  const { t } = useTranslation();
+  const { loading } = useAuth();
+  const { showPersistentToast } = useToast();
+  const firedRef = useRef(false);
+
+  useEffect(() => {
+    if (loading || firedRef.current) return;
+    if (sessionStorage.getItem(SESSION_SHOWN_KEY)) {
+      firedRef.current = true;
+      return;
+    }
+    firedRef.current = true;
+    sessionStorage.setItem(SESSION_SHOWN_KEY, '1');
+
+    (async () => {
+      try {
+        const result = await sponsorPromptApi.check();
+        if (!result.show || !result.milestone) return;
+        const message = buildMessage(t, result, currencyCode);
+        if (!message) return;
+        showPersistentToast(TOAST_ID, message, 'info', {
+          action: {
+            label: t('sponsors.viewSupporters', 'View supporters'),
+            href: `https://bambuddy.cool/sponsors.html?from=app-toast-${result.milestone}`,
+            onClick: () => {
+              void sponsorPromptApi.dismiss(result.milestone!);
+            },
+          },
+        });
+      } catch {
+        // Network / 401 — silently skip; next session retries.
+      }
+    })();
+  }, [loading, t, showPersistentToast, currencyCode]);
+}

+ 5 - 0
frontend/src/i18n/locales/de.ts

@@ -3610,6 +3610,11 @@ export default {
     sectionTitle: 'Unabhängig & von der Community finanziert',
     tagline: 'Bambuddy ist kostenlos und bleibt es, weil Menschen es freiwillig unterstützen. Kein VC, kein Cloud-Zwang.',
     viewSupporters: 'Unterstützer ansehen',
+    toastPrints: 'Du hast {{count}} Drucke mit Bambuddy abgeschlossen. Bambuddy bleibt kostenlos dank seiner Unterstützer.',
+    toastCost: 'Du hast {{total}} an Filament mit Bambuddy verfolgt. Sieh dir an, wer das Projekt unabhängig hält.',
+    toastArchives: '{{count}} Drucke mit Bambuddy archiviert. Sieh dir an, wer es unabhängig hält.',
+    toastAnniversary: 'Ein Jahr mit Bambuddy! Sieh dir an, wer das Projekt unabhängig hält.',
+    toastVersionUpdate: 'Aktualisiert auf v{{version}}. Bambuddy bleibt kostenlos dank seiner Unterstützer.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/en.ts

@@ -3622,6 +3622,11 @@ export default {
     sectionTitle: 'Independent & community-funded',
     tagline: 'Bambuddy is free and stays that way because people choose to support it. No VC, no cloud lock-in.',
     viewSupporters: 'View supporters',
+    toastPrints: "You've completed {{count}} prints with Bambuddy. Bambuddy stays free thanks to its supporters.",
+    toastCost: "You've tracked {{total}} in filament with Bambuddy. See who keeps the project independent.",
+    toastArchives: '{{count}} prints archived with Bambuddy. See who keeps it independent.',
+    toastAnniversary: 'One year with Bambuddy! See who keeps the project independent.',
+    toastVersionUpdate: 'Updated to v{{version}}. Bambuddy stays free thanks to its supporters.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/es.ts

@@ -3613,6 +3613,11 @@ export default {
     sectionTitle: 'Independiente y financiado por la comunidad',
     tagline: 'Bambuddy es gratuito y seguirá siéndolo porque hay quien decide apoyarlo. Sin VC, sin dependencia de la nube.',
     viewSupporters: 'Ver patrocinadores',
+    toastPrints: 'Has completado {{count}} impresiones con Bambuddy. Bambuddy sigue siendo gratuito gracias a quienes lo apoyan.',
+    toastCost: 'Has rastreado {{total}} de filamento con Bambuddy. Mira quién mantiene el proyecto independiente.',
+    toastArchives: '{{count}} impresiones archivadas con Bambuddy. Mira quién lo mantiene independiente.',
+    toastAnniversary: '¡Un año con Bambuddy! Mira quién mantiene el proyecto independiente.',
+    toastVersionUpdate: 'Actualizado a v{{version}}. Bambuddy sigue siendo gratuito gracias a quienes lo apoyan.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/fr.ts

@@ -3599,6 +3599,11 @@ export default {
     sectionTitle: 'Indépendant & financé par la communauté',
     tagline: 'Bambuddy est gratuit et le reste parce que des personnes choisissent de le soutenir. Pas de VC, pas de verrouillage cloud.',
     viewSupporters: 'Voir les soutiens',
+    toastPrints: 'Tu as terminé {{count}} impressions avec Bambuddy. Bambuddy reste gratuit grâce à ceux qui le soutiennent.',
+    toastCost: 'Tu as suivi {{total}} de filament avec Bambuddy. Vois qui garde le projet indépendant.',
+    toastArchives: '{{count}} impressions archivées avec Bambuddy. Vois qui le garde indépendant.',
+    toastAnniversary: 'Un an avec Bambuddy ! Vois qui garde le projet indépendant.',
+    toastVersionUpdate: 'Mis à jour vers v{{version}}. Bambuddy reste gratuit grâce à ceux qui le soutiennent.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/it.ts

@@ -3598,6 +3598,11 @@ export default {
     sectionTitle: 'Indipendente e finanziato dalla community',
     tagline: 'Bambuddy è gratuito e resta tale perché qualcuno sceglie di sostenerlo. Niente VC, niente lock-in cloud.',
     viewSupporters: 'Vedi i sostenitori',
+    toastPrints: 'Hai completato {{count}} stampe con Bambuddy. Bambuddy resta gratuito grazie a chi lo sostiene.',
+    toastCost: 'Hai monitorato {{total}} di filamento con Bambuddy. Scopri chi mantiene il progetto indipendente.',
+    toastArchives: '{{count}} stampe archiviate con Bambuddy. Scopri chi lo mantiene indipendente.',
+    toastAnniversary: 'Un anno con Bambuddy! Scopri chi mantiene il progetto indipendente.',
+    toastVersionUpdate: 'Aggiornato a v{{version}}. Bambuddy resta gratuito grazie a chi lo sostiene.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/ja.ts

@@ -3610,6 +3610,11 @@ export default {
     sectionTitle: '独立・コミュニティ運営',
     tagline: 'Bambuddyは無料で、これからも無料です。支援してくださる方々のおかげで、VCもクラウドロックインもありません。',
     viewSupporters: 'サポーター一覧',
+    toastPrints: 'Bambuddyで{{count}}件の印刷を完了しました。Bambuddyは支援者のおかげで無料で提供されています。',
+    toastCost: 'Bambuddyで{{total}}分のフィラメントを記録しました。プロジェクトを支えてくれている方々をご覧ください。',
+    toastArchives: '{{count}}件の印刷をBambuddyでアーカイブしました。独立を支えてくれている方々をご覧ください。',
+    toastAnniversary: 'Bambuddyとの1周年です!プロジェクトを支えてくれている方々をご覧ください。',
+    toastVersionUpdate: 'v{{version}}にアップデートされました。Bambuddyは支援者のおかげで無料で提供されています。',
   },
 
   // Library (K Profiles)

+ 6 - 1
frontend/src/i18n/locales/ko.ts

@@ -3407,7 +3407,12 @@ export default {
   sponsors: {
     sectionTitle: '독립적·커뮤니티 후원',
     tagline: 'Bambuddy는 무료이며, 자발적으로 후원하는 사용자 덕분에 계속 무료로 유지됩니다. VC도 없고, 클라우드 종속도 없습니다.',
-    viewSupporters: '후원자 보기'
+    viewSupporters: '후원자 보기',
+    toastPrints: 'Bambuddy로 {{count}}회 인쇄를 완료했습니다. Bambuddy는 후원자 덕분에 무료로 유지됩니다.',
+    toastCost: 'Bambuddy로 {{total}}만큼의 필라멘트를 추적했습니다. 프로젝트를 독립적으로 유지하는 사람들을 만나보세요.',
+    toastArchives: 'Bambuddy로 {{count}}회 인쇄를 아카이브했습니다. 독립성을 지지하는 사람들을 만나보세요.',
+    toastAnniversary: 'Bambuddy와 함께한 지 1년입니다! 프로젝트를 독립적으로 유지하는 사람들을 만나보세요.',
+    toastVersionUpdate: 'v{{version}}로 업데이트되었습니다. Bambuddy는 후원자 덕분에 무료로 유지됩니다.'
   },
   library: {
     title: '필라멘트 라이브러리',

+ 5 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3598,6 +3598,11 @@ export default {
     sectionTitle: 'Independente e financiado pela comunidade',
     tagline: 'O Bambuddy é gratuito e continua assim porque há quem escolha apoiá-lo. Sem VC, sem dependência de nuvem.',
     viewSupporters: 'Ver apoiadores',
+    toastPrints: 'Você concluiu {{count}} impressões com o Bambuddy. O Bambuddy continua gratuito graças a quem o apoia.',
+    toastCost: 'Você acompanhou {{total}} de filamento com o Bambuddy. Veja quem mantém o projeto independente.',
+    toastArchives: '{{count}} impressões arquivadas com o Bambuddy. Veja quem o mantém independente.',
+    toastAnniversary: 'Um ano com o Bambuddy! Veja quem mantém o projeto independente.',
+    toastVersionUpdate: 'Atualizado para v{{version}}. O Bambuddy continua gratuito graças a quem o apoia.',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/tr.ts

@@ -3599,6 +3599,11 @@ export default {
     sectionTitle: 'Bağımsız ve topluluk destekli',
     tagline: 'Bambuddy ücretsizdir ve böyle kalır çünkü insanlar onu desteklemeyi seçer. VC yok, bulut bağımlılığı yok.',
     viewSupporters: 'Destekçileri görüntüle',
+    toastPrints: 'Bambuddy ile {{count}} baskı tamamladın. Bambuddy, destekçileri sayesinde ücretsiz kalıyor.',
+    toastCost: 'Bambuddy ile {{total}} kadar filament takip ettin. Projeyi bağımsız tutanları gör.',
+    toastArchives: '{{count}} baskı Bambuddy ile arşivlendi. Bağımsız kalmasını sağlayanları gör.',
+    toastAnniversary: 'Bambuddy ile bir yılı doldurdun! Projeyi bağımsız tutanları gör.',
+    toastVersionUpdate: 'v{{version}} sürümüne güncellendi. Bambuddy, destekçileri sayesinde ücretsiz kalıyor.',
   },
 
   // Kütüphane (K Profilleri)

+ 5 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3598,6 +3598,11 @@ export default {
     sectionTitle: '独立运营·社区支持',
     tagline: 'Bambuddy 完全免费,并将持续免费——这要感谢主动支持的用户。没有风投,也没有云端绑定。',
     viewSupporters: '查看支持者',
+    toastPrints: '你已经用 Bambuddy 完成了 {{count}} 次打印。Bambuddy 之所以免费,离不开支持者。',
+    toastCost: '你已经用 Bambuddy 追踪了 {{total}} 的耗材。看看是谁让项目保持独立。',
+    toastArchives: '用 Bambuddy 归档了 {{count}} 次打印。看看是谁让它保持独立。',
+    toastAnniversary: '与 Bambuddy 相伴一年了!看看是谁让项目保持独立。',
+    toastVersionUpdate: '已更新至 v{{version}}。Bambuddy 之所以免费,离不开支持者。',
   },
 
   // Library (K Profiles)

+ 5 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3598,6 +3598,11 @@ export default {
     sectionTitle: '獨立運作·社群支持',
     tagline: 'Bambuddy 完全免費,並將持續免費——感謝主動支持的使用者。沒有創投,也沒有雲端綁定。',
     viewSupporters: '查看支持者',
+    toastPrints: '你已經用 Bambuddy 完成了 {{count}} 次列印。Bambuddy 之所以免費,要感謝支持者。',
+    toastCost: '你已經用 Bambuddy 追蹤了 {{total}} 的耗材。看看是誰讓專案保持獨立。',
+    toastArchives: '用 Bambuddy 封存了 {{count}} 次列印。看看是誰讓它保持獨立。',
+    toastAnniversary: '與 Bambuddy 相伴一年了!看看是誰讓專案保持獨立。',
+    toastVersionUpdate: '已更新至 v{{version}}。Bambuddy 之所以免費,要感謝支持者。',
   },
 
   // Library (K Profiles)

Неке датотеке нису приказане због велике количине промена