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).
This commit is contained in:
maziggy
2026-06-28 12:38:31 +02:00
25 changed files with 967 additions and 13 deletions
+4
View File
File diff suppressed because one or more lines are too long
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)