mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '필라멘트 라이브러리',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user