mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
feat(sponsors): ask a print farm for a support contract, not a $5 donation
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '필라멘트 라이브러리',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user