Просмотр исходного кода

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

maziggy 1 месяц назад
Родитель
Сommit
4d5dbe8d27

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 138 - 4
frontend/src/__tests__/hooks/useSponsorPrompt.test.tsx

@@ -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');
+  });
+});

+ 54 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -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();
+  });
+});

+ 48 - 0
frontend/src/__tests__/utils/fleetAudience.test.ts

@@ -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
frontend/src/hooks/useSponsorPrompt.ts

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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
frontend/src/i18n/locales/ko.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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 - 0
frontend/src/utils/fleetAudience.ts

@@ -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}`;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Bu5feVhv.js


+ 1 - 1
static/index.html

@@ -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>

Некоторые файлы не были показаны из-за большого количества измененных файлов