feat(sponsors): ask a print farm for a support contract, not a $5 donation

This commit is contained in:
maziggy
2026-07-13 11:28:01 +02:00
parent 095d63b24a
commit 4d5dbe8d27
20 changed files with 524 additions and 176 deletions
+1
View File
File diff suppressed because one or more lines are too long
@@ -10,8 +10,10 @@
* it can't silently regress back to click-only anchoring.
*/
import type { ReactNode } from 'react';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
@@ -29,6 +31,36 @@ vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showPersistentToast }),
}));
// The hook reads the printers list (fleet size decides which ask it makes), so
// it now needs a QueryClient. Fresh per test so one test's fleet can't leak
// into the next through the cache.
function wrapper() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
/** Make GET /printers/ return exactly `count` printers. */
function withFleet(count: number) {
server.use(
http.get('/api/v1/printers/', () =>
HttpResponse.json(
Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Printer ${i + 1}`,
serial_number: `SN${i + 1}`,
ip_address: '192.168.1.10',
model: 'X1C',
is_active: true,
})),
),
),
);
}
beforeEach(() => {
showPersistentToast.mockClear();
sessionStorage.clear();
@@ -58,7 +90,7 @@ describe('useSponsorPrompt', () => {
}),
);
renderHook(() => useSponsorPrompt('EUR'));
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
// The toast is shown...
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
@@ -82,7 +114,7 @@ describe('useSponsorPrompt', () => {
}),
);
renderHook(() => useSponsorPrompt('EUR'));
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
// Give the async effect a chance to run before asserting the negatives.
await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
@@ -100,15 +132,117 @@ describe('useSponsorPrompt', () => {
http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
);
const first = renderHook(() => useSponsorPrompt('EUR'));
const first = renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await waitFor(() => expect(checkCalls).toBe(1));
first.unmount();
// A second mount in the same session (e.g. a route change that remounts
// Layout) must not re-run the check — that per-tab guard is what keeps the
// toast from flashing repeatedly while a session is open.
renderHook(() => useSponsorPrompt('EUR'));
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await new Promise((r) => setTimeout(r, 20));
expect(checkCalls).toBe(1);
});
});
/**
* Fleet-size audience split.
*
* A print farm has no use for a "chip in $5" toast — it wants a support
* contract. At/above BUSINESS_FLEET_THRESHOLD configured printers the toast
* makes the commercial ask instead, and points at business.html rather than
* sponsors.html. Same milestone, same cooldown, same single interruption; only
* the ask changes.
*/
describe('useSponsorPrompt — fleet-size audience', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/sponsor-prompt/check', () =>
HttpResponse.json({
show: true,
milestone: 'prints-25',
family: 'prints',
threshold: 25,
payload: { count: 30 },
}),
),
http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
);
});
it('makes the personal ask below the threshold', async () => {
withFleet(4);
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
const [, message, , options] = showPersistentToast.mock.calls[0];
expect(options.action.href).toContain('sponsors.html');
expect(options.action.href).not.toContain('business.html');
expect(message).toContain('30'); // the prints milestone copy, not the fleet copy
});
it('makes the commercial ask at the threshold, pointing at business.html', async () => {
withFleet(5);
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
const [, message, , options] = showPersistentToast.mock.calls[0];
expect(options.action.href).toContain('business.html');
// Attribution still rides on the milestone, so Matomo keeps segmenting it.
expect(options.action.href).toContain('from=app-toast-prints-25');
expect(message).toContain('5'); // fleet size, not the print count
expect(message).toMatch(/support plan/i);
});
it('counts configured printers, not active ones — maintenance mode must not downgrade the ask', async () => {
// Eight printers, five of them in maintenance (is_active: false). This is
// still an eight-printer business; if the split filtered on is_active it
// would see three and pitch them as a hobbyist mid-outage.
server.use(
http.get('/api/v1/printers/', () =>
HttpResponse.json(
Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
name: `Printer ${i + 1}`,
serial_number: `SN${i + 1}`,
ip_address: '192.168.1.10',
model: 'X1C',
is_active: i < 3,
})),
),
),
);
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
const [, message, , options] = showPersistentToast.mock.calls[0];
expect(options.action.href).toContain('business.html');
expect(message).toContain('8');
});
it('waits for the fleet to load before deciding — a farm is never pitched as a hobbyist', async () => {
// Slow printers response: if the hook fired the toast before the fleet
// resolved, it would default to 0 printers and make the personal ask.
server.use(
http.get('/api/v1/printers/', async () => {
await new Promise((r) => setTimeout(r, 50));
return HttpResponse.json(
Array.from({ length: 6 }, (_, i) => ({
id: i + 1,
name: `Printer ${i + 1}`,
serial_number: `SN${i + 1}`,
ip_address: '192.168.1.10',
model: 'X1C',
is_active: true,
})),
);
}),
);
renderHook(() => useSponsorPrompt('EUR'), { wrapper: wrapper() });
await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1), { timeout: 2000 });
expect(showPersistentToast.mock.calls[0][3].action.href).toContain('business.html');
});
});
@@ -1343,3 +1343,57 @@ describe('SettingsPage', () => {
});
});
});
/**
* Sponsor banner on Settings -> General.
*
* Below the fleet threshold it makes the community/donation ask; at or above it
* the same slot makes the commercial ask and points at business.html. A print
* farm asked to chip in $5 is a wasted impression, and a hobbyist pitched a
* support contract is an annoyed user — so both directions are pinned.
*/
describe('SettingsPage — sponsor banner audience', () => {
beforeEach(() => {
// BrowserRouter shares window.location across tests and the banner only
// renders on the General tab — without this reset a prior test's ?tab=queue
// leaks in and the banner never mounts.
window.history.replaceState({}, '', '/');
});
const fleet = (count: number) =>
server.use(
http.get('/api/v1/printers/', () =>
HttpResponse.json(
Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Printer ${i + 1}`,
serial_number: `SN${i + 1}`,
ip_address: '192.168.1.10',
model: 'X1C',
is_active: true,
})),
),
),
);
it('shows the community ask for a small fleet', async () => {
fleet(2);
render(<SettingsPage />);
const banner = await screen.findByRole('link', { name: /Independent & community-funded/i });
expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/sponsors.html?from=app-settings');
expect(screen.queryByText(/Bambuddy for business/i)).not.toBeInTheDocument();
});
it('shows the commercial ask for a business-sized fleet', async () => {
fleet(6);
render(<SettingsPage />);
const banner = await screen.findByRole('link', { name: /Bambuddy for business/i });
expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/business.html?from=app-settings');
// The donation copy is replaced, not merely supplemented.
expect(screen.queryByText(/Independent & community-funded/i)).not.toBeInTheDocument();
// The ask names the fleet back to them.
expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,48 @@
/**
* Fleet-size audience split for the sponsor surfaces.
*
* The threshold decides which pitch a user sees, so it is worth pinning: a
* hobbyist must never be asked to buy a support contract, and a print farm must
* never be asked to chip in $5.
*/
import { describe, it, expect } from 'vitest';
import {
BUSINESS_FLEET_THRESHOLD,
fleetAudience,
sponsorHref,
} from '../../utils/fleetAudience';
describe('fleetAudience', () => {
it('treats a fleet below the threshold as personal', () => {
expect(fleetAudience(0)).toBe('personal');
expect(fleetAudience(1)).toBe('personal');
expect(fleetAudience(BUSINESS_FLEET_THRESHOLD - 1)).toBe('personal');
});
it('treats the threshold itself as business (inclusive boundary)', () => {
expect(fleetAudience(BUSINESS_FLEET_THRESHOLD)).toBe('business');
expect(fleetAudience(BUSINESS_FLEET_THRESHOLD + 1)).toBe('business');
expect(fleetAudience(40)).toBe('business');
});
});
describe('sponsorHref', () => {
it('sends a personal audience to the sponsor tiers', () => {
expect(sponsorHref('personal', 'app-settings')).toBe(
'https://bambuddy.cool/sponsors.html?from=app-settings',
);
});
it('sends a business audience to the commercial page', () => {
expect(sponsorHref('business', 'app-settings')).toBe(
'https://bambuddy.cool/business.html?from=app-settings',
);
});
it('preserves the Matomo attribution param on both', () => {
// The `?from=` tag is how the funnel is measured — losing it would make the
// whole surface invisible in analytics.
expect(sponsorHref('personal', 'app-toast-prints-10')).toContain('?from=app-toast-prints-10');
expect(sponsorHref('business', 'app-toast-prints-10')).toContain('?from=app-toast-prints-10');
});
});
+32 -6
View File
@@ -13,10 +13,12 @@
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { sponsorPromptApi, type SponsorPromptCheckResponse } from '../api/client';
import { api, sponsorPromptApi, type SponsorPromptCheckResponse } from '../api/client';
import { getCurrencySymbol } from '../utils/currency';
import { fleetAudience, sponsorHref, type SponsorAudience } from '../utils/fleetAudience';
const TOAST_ID = 'sponsor-prompt';
const SESSION_SHOWN_KEY = 'sponsorPromptShown';
@@ -33,7 +35,15 @@ function buildMessage(
t: ReturnType<typeof useTranslation>['t'],
trigger: SponsorPromptCheckResponse,
currencyCode: string,
audience: SponsorAudience,
printerCount: number,
): string | null {
// A business install has earned the same milestone, but the ask is different:
// support contract and invoicing, not a personal donation. One toast either
// way — the fleet only changes which one.
if (audience === 'business') {
return t('sponsors.toastBusiness', { count: printerCount });
}
const family = trigger.family;
const payload = trigger.payload ?? {};
const threshold = trigger.threshold ?? 0;
@@ -62,8 +72,18 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
const { showPersistentToast } = useToast();
const firedRef = useRef(false);
// Fleet size decides which ask the toast makes. The printers list is already
// cached app-wide, so this is normally a cache read; on a cold start we wait
// for it rather than pitch a print farm as if it were a hobbyist. An error
// (no permission, offline) settles the query too and falls back to personal.
const { data: printers, isPending: printersPending } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
retry: false,
});
useEffect(() => {
if (loading || firedRef.current) return;
if (loading || printersPending || firedRef.current) return;
if (sessionStorage.getItem(SESSION_SHOWN_KEY)) {
firedRef.current = true;
return;
@@ -71,16 +91,22 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
firedRef.current = true;
sessionStorage.setItem(SESSION_SHOWN_KEY, '1');
const printerCount = printers?.length ?? 0;
const audience = fleetAudience(printerCount);
(async () => {
try {
const result = await sponsorPromptApi.check();
if (!result.show || !result.milestone) return;
const message = buildMessage(t, result, currencyCode);
const message = buildMessage(t, result, currencyCode, audience, printerCount);
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}`,
label:
audience === 'business'
? t('sponsors.businessCta', 'Bambuddy for business')
: t('sponsors.viewSupporters', 'View supporters'),
href: sponsorHref(audience, `app-toast-${result.milestone}`),
},
});
// Anchor the 14-day cooldown as soon as the toast is on screen, so an
@@ -90,5 +116,5 @@ export function useSponsorPrompt(currencyCode = 'EUR') {
// Network / 401 — silently skip; next session retries.
}
})();
}, [loading, t, showPersistentToast, currencyCode]);
}, [loading, printersPending, printers, t, showPersistentToast, currencyCode]);
}
+4
View File
@@ -3949,6 +3949,10 @@ export default {
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.',
toastBusiness: 'Sie betreiben Bambuddy auf {{count}} Druckern. Für Teams gibt es Support-Pakete – bevorzugte Fehlerbehebung, Rechnungsstellung und einen direkten Draht zum Maintainer.',
businessCta: 'Bambuddy für Unternehmen',
businessTitle: 'Bambuddy für Unternehmen',
businessTagline: 'Sie betreiben {{count}} Drucker. Für Teams und Druckfarmen gibt es priorisierten Support, kommerzielle Lizenzen und Rechnungsstellung.',
},
// Library (K Profiles)
+4
View File
@@ -3978,6 +3978,10 @@ export default {
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.',
toastBusiness: "Running Bambuddy on {{count}} printers? There's a support plan for teams — priority fixes, invoicing, and a direct line to the maintainer.",
businessCta: 'Bambuddy for business',
businessTitle: 'Bambuddy for business',
businessTagline: "You're running {{count}} printers. Priority support, commercial licensing and invoicing are available for teams and print farms.",
},
// Library (K Profiles)
+4
View File
@@ -3952,6 +3952,10 @@ export default {
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.',
toastBusiness: '¿Usas Bambuddy en {{count}} impresoras? Existe un plan de soporte para equipos: correcciones prioritarias, facturación y contacto directo con el responsable del proyecto.',
businessCta: 'Bambuddy para empresas',
businessTitle: 'Bambuddy para empresas',
businessTagline: 'Estás gestionando {{count}} impresoras. Hay soporte prioritario, licencias comerciales y facturación disponibles para equipos y granjas de impresión.',
},
// Library (K Profiles)
+4
View File
@@ -3938,6 +3938,10 @@ export default {
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.',
toastBusiness: "Vous utilisez Bambuddy sur {{count}} imprimantes ? Il existe une offre de support pour les équipes : corrections prioritaires, facturation et contact direct avec le mainteneur.",
businessCta: "Bambuddy pour les entreprises",
businessTitle: "Bambuddy pour les entreprises",
businessTagline: "Vous gérez {{count}} imprimantes. Support prioritaire, licences commerciales et facturation sont disponibles pour les équipes et les fermes d'impression.",
},
// Library (K Profiles)
+4
View File
@@ -3937,6 +3937,10 @@ export default {
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.',
toastBusiness: 'Usi Bambuddy su {{count}} stampanti? Esiste un piano di supporto per i team: correzioni prioritarie, fatturazione e un contatto diretto con il manutentore.',
businessCta: 'Bambuddy per le aziende',
businessTitle: 'Bambuddy per le aziende',
businessTagline: 'Stai gestendo {{count}} stampanti. Supporto prioritario, licenze commerciali e fatturazione sono disponibili per team e farm di stampa.',
},
// Library (K Profiles)
+4
View File
@@ -3949,6 +3949,10 @@ export default {
toastArchives: '{{count}}件の印刷をBambuddyでアーカイブしました。独立を支えてくれている方々をご覧ください。',
toastAnniversary: 'Bambuddyとの1周年です!プロジェクトを支えてくれている方々をご覧ください。',
toastVersionUpdate: 'v{{version}}にアップデートされました。Bambuddyは支援者のおかげで無料で提供されています。',
toastBusiness: '{{count}}台のプリンターでBambuddyを運用中ですね。チーム向けのサポートプランがあります(優先対応、請求書発行、開発者への直接窓口)。',
businessCta: 'ビジネス向けBambuddy',
businessTitle: 'ビジネス向けBambuddy',
businessTagline: '{{count}}台のプリンターを運用中です。チームやプリントファーム向けに、優先サポート、商用ライセンス、請求書発行をご用意しています。',
},
// Library (K Profiles)
+5 -1
View File
@@ -3745,7 +3745,11 @@ export default {
toastCost: 'Bambuddy로 {{total}}만큼의 필라멘트를 추적했습니다. 프로젝트를 독립적으로 유지하는 사람들을 만나보세요.',
toastArchives: 'Bambuddy로 {{count}}회 인쇄를 아카이브했습니다. 독립성을 지지하는 사람들을 만나보세요.',
toastAnniversary: 'Bambuddy와 함께한 지 1년입니다! 프로젝트를 독립적으로 유지하는 사람들을 만나보세요.',
toastVersionUpdate: 'v{{version}}로 업데이트되었습니다. Bambuddy는 후원자 덕분에 무료로 유지됩니다.'
toastVersionUpdate: 'v{{version}}로 업데이트되었습니다. Bambuddy는 후원자 덕분에 무료로 유지됩니다.',
toastBusiness: '프린터 {{count}}대에서 Bambuddy를 운영 중이신가요? 팀을 위한 지원 플랜이 있습니다. 우선 수정, 인보이스 발행, 메인테이너와의 직접 소통을 제공합니다.',
businessCta: '비즈니스용 Bambuddy',
businessTitle: '비즈니스용 Bambuddy',
businessTagline: '프린터 {{count}}대를 운영 중입니다. 팀과 프린트 팜을 위한 우선 지원, 상용 라이선스, 인보이스 발행을 제공합니다.',
},
library: {
title: '필라멘트 라이브러리',
+4
View File
@@ -3937,6 +3937,10 @@ export default {
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.',
toastBusiness: 'Você usa o Bambuddy em {{count}} impressoras? Existe um plano de suporte para equipes: correções prioritárias, emissão de nota fiscal e contato direto com o mantenedor.',
businessCta: 'Bambuddy para empresas',
businessTitle: 'Bambuddy para empresas',
businessTagline: 'Você está gerenciando {{count}} impressoras. Suporte prioritário, licenciamento comercial e faturamento estão disponíveis para equipes e fazendas de impressão.',
},
// Library (K Profiles)
+4
View File
@@ -3939,6 +3939,10 @@ export default {
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.',
toastBusiness: "Bambuddy'yi {{count}} yazıcıda mı çalıştırıyorsunuz? Ekipler için bir destek planı var: öncelikli düzeltmeler, faturalandırma ve geliştiriciye doğrudan erişim.",
businessCta: 'Kurumsal Bambuddy',
businessTitle: 'Kurumsal Bambuddy',
businessTagline: '{{count}} yazıcı çalıştırıyorsunuz. Ekipler ve baskı çiftlikleri için öncelikli destek, ticari lisanslama ve faturalandırma mevcut.',
},
// Kütüphane (K Profilleri)
+4
View File
@@ -3937,6 +3937,10 @@ export default {
toastArchives: '用 Bambuddy 归档了 {{count}} 次打印。看看是谁让它保持独立。',
toastAnniversary: '与 Bambuddy 相伴一年了!看看是谁让项目保持独立。',
toastVersionUpdate: '已更新至 v{{version}}。Bambuddy 之所以免费,离不开支持者。',
toastBusiness: '您正在 {{count}} 台打印机上运行 Bambuddy?我们为团队提供支持方案:优先修复、开具发票,以及与维护者的直接沟通渠道。',
businessCta: 'Bambuddy 商业版',
businessTitle: 'Bambuddy 商业版',
businessTagline: '您正在管理 {{count}} 台打印机。我们为团队和打印农场提供优先支持、商业授权和发票开具。',
},
// Library (K Profiles)
+4
View File
@@ -3937,6 +3937,10 @@ export default {
toastArchives: '用 Bambuddy 封存了 {{count}} 次列印。看看是誰讓它保持獨立。',
toastAnniversary: '與 Bambuddy 相伴一年了!看看是誰讓專案保持獨立。',
toastVersionUpdate: '已更新至 v{{version}}。Bambuddy 之所以免費,要感謝支持者。',
toastBusiness: '您正在 {{count}} 台印表機上執行 Bambuddy?我們為團隊提供支援方案:優先修復、開立發票,以及與維護者的直接聯繫管道。',
businessCta: 'Bambuddy 商業版',
businessTitle: 'Bambuddy 商業版',
businessTagline: '您正在管理 {{count}} 台印表機。我們為團隊和列印農場提供優先支援、商業授權與發票開立。',
},
// Library (K Profiles)
+25 -10
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Workflow } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
@@ -7,6 +7,7 @@ import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import { checkPasswordComplexity } from '../utils/password';
import { fleetAudience, sponsorHref } from '../utils/fleetAudience';
import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
@@ -428,6 +429,9 @@ export function SettingsPage() {
queryFn: api.getPrinters,
});
// A business-sized fleet gets the commercial ask instead of the donation ask.
const sponsorAudience = fleetAudience(printers?.length ?? 0);
const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
queryKey: ['notification-templates'],
queryFn: api.getNotificationTemplates,
@@ -1504,30 +1508,41 @@ export function SettingsPage() {
<div className="flex-1 min-w-0">
{activeTab === 'general' && (
<>
{/* Sponsor banner — prominent independence callout */}
{/* Sponsor banner — independence callout, or the commercial ask on a
business-sized fleet (see utils/fleetAudience). */}
<a
href="https://bambuddy.cool/sponsors.html?from=app-settings"
href={sponsorHref(sponsorAudience, 'app-settings')}
target="_blank"
rel="noopener noreferrer"
className="group block mb-4 lg:mb-6 rounded-xl border border-bambu-green/30 bg-gradient-to-br from-bambu-green/15 via-bambu-green/5 to-transparent hover:border-bambu-green/50 hover:from-bambu-green/20 transition-colors"
>
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 p-4 md:p-5">
<div className="p-3 rounded-lg bg-bambu-green/20 text-bambu-green flex-shrink-0">
<Heart className="w-6 h-6" />
{sponsorAudience === 'business' ? <Briefcase className="w-6 h-6" /> : <Heart className="w-6 h-6" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-base font-semibold text-white">
{t('sponsors.sectionTitle', 'Independent & community-funded')}
{sponsorAudience === 'business'
? t('sponsors.businessTitle', 'Bambuddy for business')
: t('sponsors.sectionTitle', 'Independent & community-funded')}
</p>
<p className="text-sm text-bambu-gray mt-0.5">
{t(
'sponsors.tagline',
'Bambuddy is free and stays that way because people choose to support it. No VC, no cloud lock-in.'
)}
{sponsorAudience === 'business'
? t(
'sponsors.businessTagline',
"You're running {{count}} printers. Priority support, commercial licensing and invoicing are available for teams and print farms.",
{ count: printers?.length ?? 0 }
)
: t(
'sponsors.tagline',
'Bambuddy is free and stays that way because people choose to support it. No VC, no cloud lock-in.'
)}
</p>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bambu-green/20 text-bambu-green group-hover:bg-bambu-green/30 text-sm font-medium whitespace-nowrap self-start md:self-auto">
{t('sponsors.viewSupporters', 'View supporters')}
{sponsorAudience === 'business'
? t('sponsors.businessCta', 'Bambuddy for business')
: t('sponsors.viewSupporters', 'View supporters')}
<ExternalLink className="w-4 h-4" />
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
/**
* Fleet-size audience split for the sponsor surfaces.
*
* An install with a large fleet is almost certainly a business (print farm,
* workshop, makerspace), and a business has no use for a "chip in $5" ask — it
* wants a support contract, an invoice and a named contact. The sponsor toast
* and the Settings banner therefore swap their copy and CTA above this
* threshold instead of showing the personal ask.
*
* Counts CONFIGURED printers, not active ones: `is_active` is the
* maintenance-mode flag, so a farm with half its machines on the bench must not
* flicker back to the hobbyist pitch.
*/
export const BUSINESS_FLEET_THRESHOLD = 5;
export type SponsorAudience = 'personal' | 'business';
export function fleetAudience(printerCount: number): SponsorAudience {
return printerCount >= BUSINESS_FLEET_THRESHOLD ? 'business' : 'personal';
}
/** Landing page for each audience, carrying the Matomo `?from=` attribution. */
export function sponsorHref(audience: SponsorAudience, from: string): string {
const page = audience === 'business' ? 'business.html' : 'sponsors.html';
return `https://bambuddy.cool/${page}?from=${from}`;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-D3e4re0S.js"></script>
<script type="module" crossorigin src="/assets/index-Bu5feVhv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DASc8Ke0.css">
</head>
<body>