feat(footer): Logs icon + uniform icon sizes + value-moment donate popover (Clippy-style, strictly throttled) (#898)
Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Fable 5
parent
83e71c5689
commit
e8fdf0e244
@@ -62,6 +62,7 @@ const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loadi
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from './utils/errorToast';
|
||||
import { addBreadcrumb } from './utils/breadcrumbs';
|
||||
import { recordValueMoment } from './utils/donationMoments';
|
||||
import {
|
||||
POPULAR_LANGS,
|
||||
POPULAR_ISO,
|
||||
@@ -718,6 +719,7 @@ function App() {
|
||||
try {
|
||||
const finalName = await browserDownload(`${API}/audio/${sourceIdentifier}`, niceName);
|
||||
toast.success(i18n.t('app.toast_downloaded', { name: finalName }));
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: finalName,
|
||||
@@ -748,6 +750,7 @@ function App() {
|
||||
|
||||
await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
|
||||
toast.success(i18n.t('app.toast_exported', { name: fallbackName }));
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -796,6 +799,7 @@ function App() {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_text_file', { path: destPath, contents: text });
|
||||
toast.success(i18n.t('app.toast_saved', { path: destPath }), { id: fallbackName });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: fallbackName,
|
||||
@@ -822,6 +826,7 @@ function App() {
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(i18n.t('app.toast_saved', { path: data.path }), { id: fallbackName });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: data.display_name || fallbackName,
|
||||
@@ -844,6 +849,7 @@ function App() {
|
||||
toast.loading(i18n.t('app.toast_processing', { name: fallbackName }), { id: fallbackName });
|
||||
const finalName = await browserDownload(url, fallbackName);
|
||||
toast.success(i18n.t('app.toast_downloaded', { name: finalName }), { id: fallbackName });
|
||||
recordValueMoment('export'); // success-only donation moment
|
||||
try {
|
||||
await exportRecord({
|
||||
filename: finalName,
|
||||
|
||||
@@ -36,6 +36,11 @@ export async function listBatchJobs(status?: string, limit = 50): Promise<BatchJ
|
||||
return apiJson<BatchJob[]>(`/batch/jobs?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/** Get a single batch job (used to resolve why a job left the active list). */
|
||||
export async function getBatchJob(id: string): Promise<BatchJob> {
|
||||
return apiJson<BatchJob>(`/batch/jobs/${id}`);
|
||||
}
|
||||
|
||||
/** Enqueue a video for batch dubbing. */
|
||||
export async function enqueueBatchJob(
|
||||
file: File,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openExternal } from '../api/external';
|
||||
import { KOFI_URL, PAYPAL_URL } from '../utils/donateLinks';
|
||||
import { DONATE_LINE_COUNT } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* DonateMomentPopover — the friendly "Clippy-like" speech bubble that
|
||||
* LogsFooter anchors above the donate heart when a donation moment fires
|
||||
* (see utils/donationMoments.js for the strict eligibility gates).
|
||||
*
|
||||
* Deliberately NO backdrop and NO focus trap: it's an aside, not a modal —
|
||||
* the user can keep working and it auto-dismisses on its own. Bounce-in
|
||||
* animation collapses to a plain fade under `prefers-reduced-motion`.
|
||||
* Chrome palette throughout so it reads as part of the footer.
|
||||
*/
|
||||
|
||||
/** How long the popover lingers before quietly dismissing itself. */
|
||||
export const DONATE_POPOVER_AUTO_DISMISS_MS = 15_000;
|
||||
|
||||
// Small pill CTA shared by the Ko-fi / PayPal buttons.
|
||||
const CTA_BTN =
|
||||
'inline-flex items-center gap-[5px] px-[10px] h-[24px] rounded-[5px] cursor-pointer ' +
|
||||
'border border-solid border-transparent text-[11px] font-semibold [font-family:inherit] ' +
|
||||
'[background:var(--chrome-accent-bg)] [color:var(--chrome-fg)] transition-colors ' +
|
||||
'hover:[border-color:var(--chrome-accent-border)] hover:[color:var(--chrome-accent)]';
|
||||
|
||||
const QUIET_BTN =
|
||||
'bg-transparent border-0 cursor-pointer p-0 text-[11px] [font-family:inherit] ' +
|
||||
'[color:var(--chrome-fg-muted)] hover:[color:var(--chrome-fg)] transition-colors';
|
||||
|
||||
export default function DonateMomentPopover({ line = 0, onLater, onOptOut }) {
|
||||
const { t } = useTranslation();
|
||||
// Rotate through the friendly lines; clamp so a stale/oversized index from
|
||||
// the event detail can never resolve to a missing i18n key.
|
||||
const lineKey = `footer_donate.line_${(Math.abs(line | 0) % DONATE_LINE_COUNT) + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('footer_donate.aria')}
|
||||
data-testid="donate-moment-popover"
|
||||
className={
|
||||
'absolute bottom-[calc(100%+10px)] right-0 z-[60] w-[272px] p-[12px] rounded-[10px] ' +
|
||||
'flex flex-col gap-[10px] select-none [background:var(--chrome-bg,#1d2021)] ' +
|
||||
'border border-solid [border-color:var(--chrome-border,rgba(255,255,255,0.08))] ' +
|
||||
'shadow-[0_8px_24px_rgba(0,0,0,0.45)] ' +
|
||||
'[animation:donate-pop-in_0.45s_cubic-bezier(0.34,1.56,0.64,1)_both] ' +
|
||||
'motion-reduce:[animation:donate-fade-in_0.2s_ease-out_both]'
|
||||
}
|
||||
>
|
||||
{/* Speech-bubble tail, pointing down at the heart. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-[6px] right-[12px] w-[10px] h-[10px] rotate-45 ' +
|
||||
'[background:var(--chrome-bg,#1d2021)] ' +
|
||||
'[border-right:1px_solid_var(--chrome-border,rgba(255,255,255,0.08))] ' +
|
||||
'[border-bottom:1px_solid_var(--chrome-border,rgba(255,255,255,0.08))]'
|
||||
}
|
||||
/>
|
||||
<p className="m-0 text-[11.5px] leading-[1.55] [color:var(--chrome-fg)]">{t(lineKey)}</p>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
className={CTA_BTN}
|
||||
aria-label={t('footer_donate.kofi_aria')}
|
||||
onClick={() => {
|
||||
openExternal(KOFI_URL);
|
||||
onLater();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">☕</span> {t('footer_donate.kofi')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CTA_BTN}
|
||||
aria-label={t('footer_donate.paypal_aria')}
|
||||
onClick={() => {
|
||||
openExternal(PAYPAL_URL);
|
||||
onLater();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">💳</span> {t('footer_donate.paypal')}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className={QUIET_BTN} onClick={onLater}>
|
||||
{t('footer_donate.later')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOptOut}
|
||||
className={
|
||||
'self-end bg-transparent border-0 cursor-pointer p-0 text-[10px] [font-family:inherit] ' +
|
||||
'[color:var(--chrome-fg-dim,var(--chrome-fg-muted))] hover:underline ' +
|
||||
'hover:[color:var(--chrome-fg-muted)] transition-colors'
|
||||
}
|
||||
>
|
||||
{t('footer_donate.dont_ask')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import { APP_VERSION } from '../utils/appVersion';
|
||||
import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover';
|
||||
import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* VSCode-style bottom panel for logs. Always-visible 28 px collapsed bar
|
||||
@@ -130,11 +132,16 @@ const DISCORD_BTN =
|
||||
|
||||
const DONATE_BTN =
|
||||
'flex items-center justify-center w-[var(--chrome-icon-btn)] h-[var(--chrome-icon-btn)] shrink-0 ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ml-[4px] ' +
|
||||
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15] ' +
|
||||
'rounded-[4px] bg-transparent border-0 cursor-pointer [color:#d3869b] ' +
|
||||
'transition-[color,transform] duration-150 hover:[color:var(--chrome-accent)] hover:scale-[1.15]';
|
||||
// Idle glow vs. the gentle attention pulse while the donation-moment popover
|
||||
// is open. Split from DONATE_BTN so exactly one animation applies at a time.
|
||||
const HEART_GLOW =
|
||||
'[animation:heart-glow_2.5s_ease-in-out_infinite] motion-reduce:[animation:none]';
|
||||
const HEART_PULSE =
|
||||
'[animation:donate-heart-pulse_1.1s_ease-in-out_infinite] motion-reduce:[animation:none]';
|
||||
|
||||
function SourcePill({ source, counts, active, onClick }) {
|
||||
function SourcePill({ source, counts, active, onClick, icon: Icon }) {
|
||||
const hasErrors = counts.error > 0;
|
||||
const hasWarns = counts.warn > 0;
|
||||
// Severity color wins over active wins over muted (matches old cascade).
|
||||
@@ -155,6 +162,7 @@ function SourcePill({ source, counts, active, onClick }) {
|
||||
onClick={onClick}
|
||||
aria-label={`${source.label} logs${hasErrors ? `, ${counts.error} errors` : hasWarns ? `, ${counts.warn} warnings` : ''}`}
|
||||
>
|
||||
{Icon && <Icon size={12} className="shrink-0" aria-hidden="true" />}
|
||||
<span className="font-medium">{source.label}</span>
|
||||
{hasErrors && <span className={BADGE_ERROR}>{counts.error}</span>}
|
||||
{!hasErrors && hasWarns && <span className={BADGE_WARN}>{counts.warn}</span>}
|
||||
@@ -293,6 +301,22 @@ export default function LogsFooter() {
|
||||
const notifQuery = useNotifications();
|
||||
const notifications = notifQuery.data?.notifications || [];
|
||||
|
||||
// ── Donation moment popover (see utils/donationMoments.js) ─────────────
|
||||
// The eligibility engine dispatches DONATION_MOMENT_EVENT after a rare,
|
||||
// gated value-creation success; the footer just renders the speech bubble
|
||||
// above the heart and auto-dismisses it. null = closed.
|
||||
const [donateMoment, setDonateMoment] = useState(null);
|
||||
useEffect(() => {
|
||||
const onMoment = (e) => setDonateMoment({ line: e?.detail?.line ?? 0 });
|
||||
window.addEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
return () => window.removeEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!donateMoment) return undefined;
|
||||
const timer = setTimeout(() => setDonateMoment(null), DONATE_POPOVER_AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [donateMoment]);
|
||||
|
||||
// Allow header bell to open notifications tab
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -452,6 +476,7 @@ export default function LogsFooter() {
|
||||
Expanding reveals the per-source filter tabs below. */
|
||||
<SourcePill
|
||||
source={{ id: 'logs', label: t('logs.title') }}
|
||||
icon={FileText}
|
||||
counts={mergedCounts}
|
||||
active={false}
|
||||
onClick={() => openTo(SOURCES.some((s) => s.id === active) ? active : 'backend')}
|
||||
@@ -583,15 +608,35 @@ export default function LogsFooter() {
|
||||
>
|
||||
<Mail size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={DONATE_BTN}
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
>
|
||||
<DonateHeart />
|
||||
</button>
|
||||
<div className="relative inline-flex shrink-0 ml-[4px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`${DONATE_BTN} ${donateMoment ? HEART_PULSE : HEART_GLOW}`}
|
||||
onClick={() => {
|
||||
// Manual entry is unchanged: the heart always opens the full
|
||||
// donate view (and quietly retires an open popover).
|
||||
setDonateMoment(null);
|
||||
useAppStore.getState().setMode?.('donate');
|
||||
}}
|
||||
title={t('logs.support_project')}
|
||||
aria-label={t('logs.support_project_aria')}
|
||||
>
|
||||
<DonateHeart />
|
||||
</button>
|
||||
{donateMoment && (
|
||||
<DonateMomentPopover
|
||||
line={donateMoment.line}
|
||||
onLater={() => setDonateMoment(null)}
|
||||
onOptOut={() => {
|
||||
optOutOfDonationMoments();
|
||||
// Mirror into the legacy postcard flag so both engines stay
|
||||
// permanently silenced no matter which one is wired.
|
||||
useAppStore.getState().optOutOfDonation?.();
|
||||
setDonateMoment(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ export default function NetworkToggle() {
|
||||
disabled={busy}
|
||||
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
|
||||
>
|
||||
{st.enabled ? <Wifi size={12} /> : <WifiOff size={12} />}
|
||||
{/* 14px matches the footer's other right-side icons (Discord, Mail,
|
||||
donate heart) — keep them optically uniform. */}
|
||||
{st.enabled ? <Wifi size={14} /> : <WifiOff size={14} />}
|
||||
<span>
|
||||
{busy ? t('network.switching') : st.enabled ? t('network.network') : t('network.local')}
|
||||
</span>
|
||||
|
||||
@@ -44,7 +44,7 @@ import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Menu } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
import { evaluateDonationPrompt } from './donate/evaluateDonationPrompt';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
import {
|
||||
parseStoryText,
|
||||
hasStoryMarkers,
|
||||
@@ -570,9 +570,9 @@ export default function StoriesEditor({ profiles = [] }) {
|
||||
if (!output) throw new Error('no output produced');
|
||||
downloadUrl(audioUrl(output), output.split('/').pop());
|
||||
toast.success(t('stories.exportDone'));
|
||||
// Success-only donation prompt (#007) — a finished longform export is a
|
||||
// real deliverable. Stays out of the catch/error branch below.
|
||||
evaluateDonationPrompt('longform');
|
||||
// Success-only donation moment — a finished audiobook export is a real
|
||||
// deliverable. Stays out of the catch/error branch below.
|
||||
recordValueMoment('audiobook');
|
||||
} catch (err) {
|
||||
console.warn('Story render failed:', err);
|
||||
toast.error(t('stories.exportFailed'));
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { openExternal } from '../../api/external';
|
||||
import GoalBar from './GoalBar';
|
||||
import Pip from './Pip';
|
||||
|
||||
const SPONSOR_URL = 'https://github.com/sponsors/debpalash';
|
||||
const STAR_URL = 'https://github.com/debpalash/OmniVoice-Studio';
|
||||
|
||||
/**
|
||||
* Postcard — the kawaii "Fund Claude Max" prompt, rendered as a NON-BLOCKING
|
||||
* react-hot-toast custom toast. No backdrop, no focus steal, never covers the
|
||||
* result (it lives in the bottom-right corner). Auto-dismisses (~12s) and
|
||||
* pauses on hover. Anti-dark-pattern by construction.
|
||||
*
|
||||
* Actions:
|
||||
* - "Chip in ❤️" → opens GitHub Sponsors, marks done, dismiss.
|
||||
* - "Maybe later" → soft dismiss (cooldown already anchored when shown).
|
||||
* - "Don't ask again" (quiet) → terminal opt-out.
|
||||
* - "⭐ Star on GitHub" (free way to help) → opens repo.
|
||||
*
|
||||
* Lead copy varies by milestone (spec.md variants).
|
||||
*/
|
||||
function leadKey(milestone) {
|
||||
switch (milestone) {
|
||||
case 'first-clone':
|
||||
return {
|
||||
k: 'donate.postcard.lead_first_clone',
|
||||
d: 'Your first voice clone is done — nice! OmniVoice runs entirely on your machine, and your support keeps it that way.',
|
||||
};
|
||||
case 'tenth-dub':
|
||||
return {
|
||||
k: 'donate.postcard.lead_tenth_dub',
|
||||
d: "Ten dubs in — you're clearly putting it to work. A small monthly chip-in funds the Claude Max that ships these features.",
|
||||
};
|
||||
case 'sustained-30d':
|
||||
return {
|
||||
k: 'donate.postcard.lead_sustained',
|
||||
d: "You've been with OmniVoice for a month. If it's earned a spot in your workflow, consider helping fund what's next.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
k: 'donate.postcard.lead_default',
|
||||
d: 'Glad that worked! OmniVoice is free and fully local. If it saves you time, a small monthly chip-in funds the Claude Max behind it.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Postcard({
|
||||
t: tt,
|
||||
milestone = null,
|
||||
progress = null,
|
||||
onDismiss,
|
||||
onOptOut,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lead = leadKey(milestone);
|
||||
|
||||
const onChipIn = () => {
|
||||
openExternal(SPONSOR_URL);
|
||||
onDismiss?.();
|
||||
};
|
||||
const onStar = () => {
|
||||
openExternal(STAR_URL);
|
||||
};
|
||||
const onSupportPage = () => {
|
||||
useAppStore.getState().setMode?.('donate');
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`postcard ${tt?.visible ? '' : 'is-leaving'}`} role="status" aria-live="polite">
|
||||
{/* dot-grain texture + perforation are pure CSS pseudo-elements */}
|
||||
<span className="postcard__grain" aria-hidden="true" />
|
||||
|
||||
<div className="postcard__stamp" aria-hidden="true">
|
||||
<Pip size={30} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__close absolute top-[6px] right-[8px] w-[20px] h-[20px] flex items-center justify-center [border:none] bg-transparent text-[var(--chrome-fg-dim)] text-[16px] leading-none cursor-pointer rounded-[var(--chrome-radius-pill)] z-[2] [transition:color_var(--dur-fast),background_var(--dur-fast)]"
|
||||
onClick={() => onDismiss?.()}
|
||||
aria-label={t('donate.postcard.dismiss_aria', { defaultValue: 'Dismiss' })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="postcard__body relative z-[1] ml-[60px] flex flex-col gap-[8px]">
|
||||
<div className="postcard__title font-serif text-[0.98rem] font-medium tracking-[-0.01em] text-[var(--chrome-fg)]">
|
||||
{t('donate.postcard.title', { defaultValue: 'Fund Claude Max' })}
|
||||
</div>
|
||||
<p className="postcard__lead m-0 font-sans text-[0.72rem] leading-[1.5] text-[var(--chrome-fg-muted)]">
|
||||
{t(lead.k, { defaultValue: lead.d })}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__goal-link block w-full p-0 my-[2px] mx-0 bg-transparent [border:none] text-left cursor-pointer"
|
||||
onClick={onSupportPage}
|
||||
>
|
||||
<GoalBar mini progress={progress} />
|
||||
</button>
|
||||
|
||||
<div className="postcard__actions flex items-center gap-[8px] mt-[2px]">
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__cta flex-1 px-[12px] py-[7px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] text-[var(--chrome-fg)] font-sans text-[0.74rem] font-semibold cursor-pointer [transition:background_var(--dur-fast),transform_var(--dur-base),border-color_var(--dur-fast)]"
|
||||
onClick={onChipIn}
|
||||
>
|
||||
{t('donate.postcard.chip_in', { defaultValue: 'Chip in' })} ❤️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__later px-[10px] py-[7px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-transparent text-[var(--chrome-fg-muted)] font-sans text-[0.72rem] cursor-pointer [transition:color_var(--dur-fast),border-color_var(--dur-fast)]"
|
||||
onClick={() => onDismiss?.()}
|
||||
>
|
||||
{t('donate.postcard.later', { defaultValue: 'Maybe later' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="postcard__minor flex items-center justify-between gap-[8px] mt-[2px]">
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__star inline-flex items-center gap-[4px] py-[2px] px-0 [border:none] bg-transparent font-mono text-[0.64rem] tracking-[0.02em] cursor-pointer [transition:color_var(--dur-fast)]"
|
||||
onClick={onStar}
|
||||
>
|
||||
<Star size={11} /> {t('donate.postcard.star', { defaultValue: 'Star on GitHub' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="postcard__optout inline-flex items-center gap-[4px] py-[2px] px-0 [border:none] bg-transparent font-mono text-[0.64rem] tracking-[0.02em] cursor-pointer [transition:color_var(--dur-fast)]"
|
||||
onClick={() => onOptOut?.()}
|
||||
>
|
||||
{t('donate.postcard.opt_out', { defaultValue: "Don't ask again" })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAppStore } from '../../store';
|
||||
import { loadDonationProgress } from '../../api/donation';
|
||||
import Postcard from './Postcard';
|
||||
|
||||
/**
|
||||
* The ONE shared entry point that decides whether to surface the "Fund Claude
|
||||
* Max" postcard after a successful action, and renders it as a non-blocking
|
||||
* custom toast if so.
|
||||
*
|
||||
* Call this right after a *successful* completePill(...) / clone-save resolve —
|
||||
* NEVER on the error / in-progress / setup / first-run path. The slice's state
|
||||
* machine (grace, ≤1/session, escalating cooldowns, opt-out, milestones) makes
|
||||
* the final call; this function just wires it to the toast UI.
|
||||
*
|
||||
* @param {'clone'|'dub'|'longform'|'generic'} kind which success happened
|
||||
* @param {{ now?: number }} [opts]
|
||||
* @returns {boolean} whether a postcard was shown
|
||||
*/
|
||||
export function evaluateDonationPrompt(kind = 'generic', opts = {}) {
|
||||
const store = useAppStore.getState();
|
||||
const decision = store.recordDonationSuccess(kind, opts.now);
|
||||
if (!decision.show) return false;
|
||||
|
||||
// Mark shown immediately so a second rapid success in the same tick can't
|
||||
// double-fire (the session cap + cooldown anchor are set right away).
|
||||
store.markDonationShown(decision.milestone, opts.now);
|
||||
|
||||
const id = `donate-postcard-${Date.now()}`;
|
||||
|
||||
// Best-effort fetch the freshest progress for the mini bar inside the card;
|
||||
// Postcard falls back to the bundled snapshot if this is slow/offline.
|
||||
let progress = null;
|
||||
loadDonationProgress()
|
||||
.then((p) => {
|
||||
progress = p;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
toast.custom(
|
||||
(tt) => (
|
||||
<Postcard
|
||||
t={tt}
|
||||
milestone={decision.milestone}
|
||||
progress={progress}
|
||||
onDismiss={() => toast.dismiss(id)}
|
||||
onOptOut={() => {
|
||||
useAppStore.getState().optOutOfDonation();
|
||||
toast.dismiss(id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id,
|
||||
duration: 12000, // auto-dismiss ~12s
|
||||
position: 'bottom-right',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { playPing } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import { evaluateDonationPrompt } from '../components/donate/evaluateDonationPrompt';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
import i18next from 'i18next';
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
@@ -926,9 +926,9 @@ export default function useDubWorkflow({
|
||||
loadProjects();
|
||||
playPing();
|
||||
useAppStore.getState().completePill(t('dub_workflow.dub_complete'));
|
||||
// Success-only donation prompt (#007) — a finished dub is a real
|
||||
// Success-only donation moment — a finished dub is a real
|
||||
// deliverable. Never fires on the error / cancel branches below.
|
||||
evaluateDonationPrompt('dub');
|
||||
recordValueMoment('dub');
|
||||
} else {
|
||||
useAppStore.getState().dismissPill();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PRESETS } from '../utils/constants';
|
||||
import { instructToFormValue } from '../utils/voiceInstruct';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { evaluateDonationPrompt } from '../components/donate/evaluateDonationPrompt';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* Encapsulates voice-profile CRUD, lock/unlock, preview, and save-from-history.
|
||||
@@ -62,9 +62,9 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
|
||||
setShowSaveProfile(false);
|
||||
setProfileName('');
|
||||
await loadProfiles();
|
||||
// Success-only donation prompt (#007). A saved voice clone is a real
|
||||
// deliverable — and the *first* one triggers the 'first-clone' milestone.
|
||||
evaluateDonationPrompt('clone');
|
||||
// Success-only donation moment — a saved voice clone is a real
|
||||
// deliverable. Never fires on the error branch below.
|
||||
recordValueMoment('clone');
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
|
||||
@@ -2038,6 +2038,19 @@
|
||||
"star_github": "Star on GitHub",
|
||||
"join_discord": "Join Discord"
|
||||
},
|
||||
"footer_donate": {
|
||||
"aria": "A friendly note from OmniVoice",
|
||||
"line_1": "That export was 100% local — no cloud, no fees. If OmniVoice saves you time, a coffee keeps it alive.",
|
||||
"line_2": "Another one finished, entirely on your machine. OmniVoice is one developer plus supporters like you — a small tip goes a long way.",
|
||||
"line_3": "You just made that with zero API keys and zero subscriptions. If it was worth a coffee, that keeps the updates coming.",
|
||||
"line_4": "Glad that worked! OmniVoice stays free and open source — and supporters are what keep it that way.",
|
||||
"kofi": "Ko-fi",
|
||||
"kofi_aria": "Support OmniVoice on Ko-fi",
|
||||
"paypal": "PayPal",
|
||||
"paypal_aria": "Support OmniVoice via PayPal",
|
||||
"later": "Later",
|
||||
"dont_ask": "Don't ask again"
|
||||
},
|
||||
"contact": {
|
||||
"hero_title": "Get in touch",
|
||||
"hero_desc": "Questions, bugs, licensing, or just to say hi — here’s how to reach me.",
|
||||
|
||||
@@ -2795,6 +2795,23 @@ input[type="file"]::file-selector-button:hover {
|
||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.08); }
|
||||
}
|
||||
/* Donation-moment popover (DonateMomentPopover.jsx): Clippy-esque bounce-in,
|
||||
collapsing to a plain fade under prefers-reduced-motion (the JSX swaps the
|
||||
animation via motion-reduce:). donate-heart-pulse is the gentle attention
|
||||
pulse the heart runs only while the popover is open. */
|
||||
@keyframes donate-pop-in {
|
||||
0% { opacity: 0; transform: translateY(10px) scale(0.9); }
|
||||
60% { opacity: 1; transform: translateY(-3px) scale(1.03); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes donate-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes donate-heart-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.22); }
|
||||
}
|
||||
|
||||
/* ═══ from src/components/CaptureWidget.css ═══ */
|
||||
/* ── Capture Pill ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { Panel, Button, Badge, Tabs } from '../ui';
|
||||
import { listBatchJobs, cancelBatchJob, deleteBatchJob, enqueueBatchJob } from '../api/batch';
|
||||
import {
|
||||
listBatchJobs,
|
||||
getBatchJob,
|
||||
cancelBatchJob,
|
||||
deleteBatchJob,
|
||||
enqueueBatchJob,
|
||||
} from '../api/batch';
|
||||
import { API } from '../api/client';
|
||||
import BatchAddDialog from '../components/BatchAddDialog';
|
||||
import toast from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { recordValueMoment } from '../utils/donationMoments';
|
||||
|
||||
/**
|
||||
* BatchQueue — UI for the /batch/* dubbing pipeline.
|
||||
@@ -65,17 +72,41 @@ export default function BatchQueue({ onBack }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
// Ids last seen queued/running. The 'active' filter excludes finished jobs
|
||||
// server-side, so a job VANISHING from the active list is the completion
|
||||
// signal — resolve its final status to tell done apart from failed/cancelled.
|
||||
const activeIdsRef = useRef(new Set());
|
||||
|
||||
const resolveFinishedJob = useCallback(async (id) => {
|
||||
try {
|
||||
const job = await getBatchJob(id);
|
||||
// Success-only donation moment — a whole batch dub job finishing is a
|
||||
// real deliverable. Failed/cancelled jobs never count.
|
||||
if (job?.status === 'done') recordValueMoment('batch');
|
||||
} catch {
|
||||
/* job deleted or backend unreachable — not a completion */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const statusParam = tab === 'active' ? 'active' : tab;
|
||||
setJobs(await listBatchJobs(statusParam, 100));
|
||||
const next = await listBatchJobs(statusParam, 100);
|
||||
setJobs(next);
|
||||
if (statusParam === 'active') {
|
||||
const nextIds = new Set(next.map((j) => j.id));
|
||||
for (const id of activeIdsRef.current) {
|
||||
if (!nextIds.has(id)) resolveFinishedJob(id);
|
||||
}
|
||||
activeIdsRef.current = nextIds;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('batch queue load failed', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab]);
|
||||
}, [tab, resolveFinishedJob]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
|
||||
@@ -19,10 +19,9 @@ import { openExternal } from '../api/external';
|
||||
import GoalBar from '../components/donate/GoalBar';
|
||||
import { loadDonationProgress, BUNDLED_PROGRESS } from '../api/donation';
|
||||
|
||||
// GitHub Sponsors isn't available, so donations go through Ko-fi or PayPal and
|
||||
// the supporter picks which — no default-charge nudge, none pre-selected.
|
||||
const KOFI_URL = 'https://ko-fi.com/debpalash';
|
||||
const PAYPAL_URL = 'https://paypal.me/palashCoder';
|
||||
// Ko-fi / PayPal destinations are shared with the footer's donation-moment
|
||||
// popover — single source of truth in utils/donateLinks.js.
|
||||
import { KOFI_URL, PAYPAL_URL } from '../utils/donateLinks';
|
||||
// Suggested amounts — ladder starts at $10; middle ($20) is "most common".
|
||||
const SUGGESTED_AMOUNTS = [
|
||||
{ value: 10, label: '$10' },
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
* Donation prompt slice — the "Fund Claude Max" kawaii postcard state machine
|
||||
* (spec 007, Phase 2/3).
|
||||
*
|
||||
* NOTE (footer donation moments): the postcard UI and its call sites were
|
||||
* superseded by `utils/donationMoments.js` + the LogsFooter popover — the
|
||||
* single donation-prompt surface now. This slice stays because its persisted
|
||||
* `optedOut` flag is a promise made to existing users ("Don't ask again" is
|
||||
* terminal): donationMoments honors it via the persisted `omnivoice.app`
|
||||
* blob, and the popover's opt-out mirrors into it. Do not re-wire prompts
|
||||
* through `recordDonationSuccess` without consolidating with donationMoments.
|
||||
*
|
||||
* Design goals (anti-dark-pattern):
|
||||
* - NEVER on error / in-progress / setup / first-run.
|
||||
* - Success-only: only ever evaluated right after a *successful* completion.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// LogsFooter donation-moment popover — render-level coverage: the footer
|
||||
// hears DONATION_MOMENT_EVENT, anchors the speech bubble above the heart,
|
||||
// pulses the heart while open, and honors Later / Don't-ask-again / the
|
||||
// ~15s auto-dismiss. Storage is the mocked localStorage from setup.js; the
|
||||
// eligibility RNG/clock are injected where the engine is driven end-to-end.
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useSystemLogs: () => ({ data: null, refetch: vi.fn() }),
|
||||
useTauriLogs: () => ({ data: null, refetch: vi.fn() }),
|
||||
useNotifications: () => ({ data: null }),
|
||||
}));
|
||||
vi.mock('../api/system', () => ({
|
||||
clearSystemLogs: vi.fn(),
|
||||
clearTauriLogs: vi.fn(),
|
||||
}));
|
||||
// NetworkToggle fetches /system/network/state on mount — out of scope here.
|
||||
vi.mock('../components/NetworkToggle', () => ({ default: () => null }));
|
||||
|
||||
const { openExternal } = vi.hoisted(() => ({ openExternal: vi.fn() }));
|
||||
vi.mock('../api/external', () => ({ openExternal }));
|
||||
|
||||
import LogsFooter from '../components/LogsFooter';
|
||||
import { DONATE_POPOVER_AUTO_DISMISS_MS } from '../components/DonateMomentPopover';
|
||||
import {
|
||||
recordValueMoment,
|
||||
_resetDonationSessionForTests,
|
||||
DONATION_MOMENT_EVENT,
|
||||
MIN_LIFETIME_MOMENTS,
|
||||
FIRST_PROMPT_MIN_DAYS,
|
||||
LS_MOMENT_COUNT,
|
||||
LS_FIRST_MOMENT_AT,
|
||||
LS_OPT_OUT,
|
||||
} from '../utils/donationMoments';
|
||||
import { KOFI_URL, PAYPAL_URL } from '../utils/donateLinks';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function fireMoment(line = 0) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DONATION_MOMENT_EVENT, { detail: { kind: 'export', line } }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const popover = () => screen.queryByTestId('donate-moment-popover');
|
||||
const heartBtn = () => screen.getByLabelText('Support this project');
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
_resetDonationSessionForTests();
|
||||
openExternal.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('LogsFooter donation-moment popover', () => {
|
||||
it('is hidden by default and appears on the donation-moment event', () => {
|
||||
render(<LogsFooter />);
|
||||
expect(popover()).toBeNull();
|
||||
|
||||
fireMoment(0);
|
||||
expect(popover()).toBeInTheDocument();
|
||||
// Line 1 copy (en), Ko-fi + PayPal CTAs, Later, and the quiet opt-out.
|
||||
expect(screen.getByText(/100% local/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Support OmniVoice on Ko-fi' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Support OmniVoice via PayPal' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: "Don't ask again" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows end-to-end when the eligibility engine fires (mocked storage + random)', () => {
|
||||
render(<LogsFooter />);
|
||||
// Seed persisted history: past the lifetime minimum, first moment long ago.
|
||||
localStorage.setItem(LS_MOMENT_COUNT, String(MIN_LIFETIME_MOMENTS));
|
||||
localStorage.setItem(
|
||||
LS_FIRST_MOMENT_AT,
|
||||
String(Date.now() - (FIRST_PROMPT_MIN_DAYS + 1) * DAY),
|
||||
);
|
||||
act(() => {
|
||||
const res = recordValueMoment('export', { random: () => 0 }); // roll always wins
|
||||
expect(res.show).toBe(true);
|
||||
});
|
||||
expect(popover()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pulses the heart while open, and "Later" quietly dismisses', () => {
|
||||
render(<LogsFooter />);
|
||||
expect(heartBtn().className).toContain('heart-glow');
|
||||
|
||||
fireMoment(1);
|
||||
expect(heartBtn().className).toContain('donate-heart-pulse');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Later' }));
|
||||
expect(popover()).toBeNull();
|
||||
expect(heartBtn().className).toContain('heart-glow');
|
||||
// "Later" must NOT opt the user out.
|
||||
expect(localStorage.getItem(LS_OPT_OUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('"Don\'t ask again" sets the permanent opt-out (new + legacy flags)', () => {
|
||||
render(<LogsFooter />);
|
||||
fireMoment(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: "Don't ask again" }));
|
||||
expect(popover()).toBeNull();
|
||||
expect(localStorage.getItem(LS_OPT_OUT)).toBe('1');
|
||||
expect(useAppStore.getState().optedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('Ko-fi / PayPal CTAs open the existing donate links and dismiss', () => {
|
||||
render(<LogsFooter />);
|
||||
fireMoment(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Support OmniVoice on Ko-fi' }));
|
||||
expect(openExternal).toHaveBeenCalledWith(KOFI_URL);
|
||||
expect(popover()).toBeNull();
|
||||
|
||||
fireMoment(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Support OmniVoice via PayPal' }));
|
||||
expect(openExternal).toHaveBeenCalledWith(PAYPAL_URL);
|
||||
expect(popover()).toBeNull();
|
||||
});
|
||||
|
||||
it(`auto-dismisses after ${DONATE_POPOVER_AUTO_DISMISS_MS / 1000}s`, () => {
|
||||
vi.useFakeTimers();
|
||||
render(<LogsFooter />);
|
||||
fireMoment(3);
|
||||
expect(popover()).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DONATE_POPOVER_AUTO_DISMISS_MS - 1);
|
||||
});
|
||||
expect(popover()).toBeInTheDocument();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(popover()).toBeNull();
|
||||
});
|
||||
|
||||
it('manual entry is unchanged: the heart still opens the donate view', () => {
|
||||
render(<LogsFooter />);
|
||||
fireMoment(0);
|
||||
fireEvent.click(heartBtn());
|
||||
// Popover retires and the app routes to the existing donate mode.
|
||||
expect(popover()).toBeNull();
|
||||
expect(useAppStore.getState().mode).toBe('donate');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock react-hot-toast so no real timers / DOM toasts are scheduled.
|
||||
// vi.hoisted lets the (hoisted) vi.mock factory reference these spies safely.
|
||||
const { toastCustom, toastDismiss } = vi.hoisted(() => ({
|
||||
toastCustom: vi.fn(),
|
||||
toastDismiss: vi.fn(),
|
||||
}));
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { custom: toastCustom, dismiss: toastDismiss },
|
||||
toast: { custom: toastCustom, dismiss: toastDismiss },
|
||||
}));
|
||||
|
||||
// Keep the data fetch deterministic + side-effect free.
|
||||
vi.mock('../api/donation', () => ({
|
||||
loadDonationProgress: () =>
|
||||
Promise.resolve({
|
||||
raised: 100,
|
||||
goal: 200,
|
||||
currency: 'USD',
|
||||
sponsorCount: 9,
|
||||
updated: '2026-06-16',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { evaluateDonationPrompt } from '../components/donate/evaluateDonationPrompt';
|
||||
import { useAppStore } from '../store';
|
||||
import { INITIAL_DONATION } from '../store/donationSlice';
|
||||
|
||||
function resetDonation() {
|
||||
useAppStore.setState({ ...INITIAL_DONATION });
|
||||
}
|
||||
|
||||
describe('evaluateDonationPrompt — success-only, gated, non-blocking', () => {
|
||||
beforeEach(() => {
|
||||
resetDonation();
|
||||
toastCustom.mockClear();
|
||||
toastDismiss.mockClear();
|
||||
});
|
||||
|
||||
it('does NOT show during the grace window (early successes)', () => {
|
||||
expect(evaluateDonationPrompt('generic')).toBe(false);
|
||||
expect(evaluateDonationPrompt('generic')).toBe(false);
|
||||
expect(evaluateDonationPrompt('generic')).toBe(false); // 3rd success = still grace boundary→eligible only AFTER
|
||||
expect(toastCustom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows exactly one postcard once past grace, then session-caps', () => {
|
||||
// burn the grace window
|
||||
evaluateDonationPrompt('generic');
|
||||
evaluateDonationPrompt('generic');
|
||||
evaluateDonationPrompt('generic');
|
||||
// next eligible success → shows
|
||||
expect(evaluateDonationPrompt('generic')).toBe(true);
|
||||
expect(toastCustom).toHaveBeenCalledTimes(1);
|
||||
// a second success the same session must NOT show again
|
||||
expect(evaluateDonationPrompt('generic')).toBe(false);
|
||||
expect(toastCustom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never fires after opt-out (terminal)', () => {
|
||||
evaluateDonationPrompt('generic');
|
||||
evaluateDonationPrompt('generic');
|
||||
evaluateDonationPrompt('generic');
|
||||
useAppStore.getState().optOutOfDonation();
|
||||
useAppStore.getState().resetDonationSession();
|
||||
expect(evaluateDonationPrompt('generic')).toBe(false);
|
||||
expect(toastCustom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is the only path that surfaces the postcard — never invoked on errors', () => {
|
||||
// This is a guard test documenting the contract: the error branches in
|
||||
// useDubWorkflow / useProfiles / StoriesEditor call errorPill/toast.error
|
||||
// and NEVER evaluateDonationPrompt. Here we assert that simply NOT calling
|
||||
// the evaluator leaves the toast untouched (i.e. nothing auto-fires).
|
||||
useAppStore.getState().errorPill?.('boom');
|
||||
expect(toastCustom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Single source of truth for donation destinations. Referenced by the
|
||||
// Support page (full donate view) and the footer's donation-moment popover,
|
||||
// so a link change can never leave one surface pointing somewhere stale.
|
||||
// GitHub Sponsors isn't available, so donations go through Ko-fi or PayPal
|
||||
// and the supporter picks which — no default-charge nudge, none pre-selected.
|
||||
export const KOFI_URL = 'https://ko-fi.com/debpalash';
|
||||
export const PAYPAL_URL = 'https://paypal.me/palashCoder';
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Donation moments — the "kind Clippy" eligibility engine behind the footer's
|
||||
* donation popover. This is the ONE donation-prompt decision point in the app
|
||||
* (it supersedes the spec-007 postcard toast; those call sites now route
|
||||
* here, so a single success can never fire two competing donation UIs).
|
||||
*
|
||||
* Modeled on what actually works for major OSS — value-moment timing, strict
|
||||
* rarity, permanent opt-out — and explicitly NOT core-js-style nagging:
|
||||
*
|
||||
* - Call `recordValueMoment(kind)` ONLY right after a genuine
|
||||
* value-creation success (export saved, dub finished, audiobook rendered,
|
||||
* batch job done, clone saved). Never on errors, never at app start.
|
||||
* - A prompt is shown only when EVERY gate passes:
|
||||
* (a) ≥ MIN_LIFETIME_MOMENTS lifetime value-moments — brand-new users
|
||||
* are never prompted;
|
||||
* (b) ≥ PROMPT_COOLDOWN_DAYS since the last prompt was shown; the FIRST
|
||||
* prompt additionally waits ≥ FIRST_PROMPT_MIN_DAYS after the very
|
||||
* first value-moment;
|
||||
* (c) not permanently opted out — "Don't ask again" is terminal, and
|
||||
* the legacy postcard opt-out persisted inside `omnivoice.app` is
|
||||
* honored too;
|
||||
* (d) at most once per app session;
|
||||
* (e) a PROMPT_PROBABILITY random roll — most eligible moments stay
|
||||
* silent, so the prompt reads as a rare aside, not a toll booth.
|
||||
* - State lives in localStorage under `omnivoice.donate.*` so it survives
|
||||
* restarts without touching the app DB.
|
||||
*
|
||||
* Pure module: the clock and the RNG are injectable so every gate is
|
||||
* deterministic under test. The one deliberate side effect: when a prompt IS
|
||||
* eligible, the shown-state is committed immediately (session cap + cooldown
|
||||
* anchor — a second success in the same tick cannot double-fire) and
|
||||
* DONATION_MOMENT_EVENT is dispatched so LogsFooter can render the popover.
|
||||
* Call sites stay one line and never handle UI.
|
||||
*/
|
||||
|
||||
// ── Gate thresholds (every threshold a named constant) ─────────────────────
|
||||
/** (a) Lifetime value-moments required before the first prompt is possible. */
|
||||
export const MIN_LIFETIME_MOMENTS = 5;
|
||||
/** (b) Days that must pass between two prompts. */
|
||||
export const PROMPT_COOLDOWN_DAYS = 7;
|
||||
/** (b) Days after the FIRST value-moment before the first prompt may show. */
|
||||
export const FIRST_PROMPT_MIN_DAYS = 3;
|
||||
/** (e) Probability that an otherwise-eligible moment actually prompts. */
|
||||
export const PROMPT_PROBABILITY = 0.25;
|
||||
|
||||
/** Number of rotating friendly lines (footer_donate.line_1..N in en.json). */
|
||||
export const DONATE_LINE_COUNT = 4;
|
||||
|
||||
/** Window event dispatched when a prompt should show. detail: {kind, line}. */
|
||||
export const DONATION_MOMENT_EVENT = 'omnivoice:donation-moment';
|
||||
|
||||
// ── localStorage keys (omnivoice.donate.*) ─────────────────────────────────
|
||||
export const LS_MOMENT_COUNT = 'omnivoice.donate.momentCount';
|
||||
export const LS_FIRST_MOMENT_AT = 'omnivoice.donate.firstMomentAt';
|
||||
export const LS_LAST_PROMPT_AT = 'omnivoice.donate.lastPromptAt';
|
||||
export const LS_PROMPT_COUNT = 'omnivoice.donate.promptCount';
|
||||
export const LS_OPT_OUT = 'omnivoice.donate.optOut';
|
||||
/** zustand persist blob — the postcard-era `optedOut` flag lives in here. */
|
||||
const LEGACY_STORE_KEY = 'omnivoice.app';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** (d) Session cap — module state, reset only by a fresh app launch. */
|
||||
let sessionPromptShown = false;
|
||||
|
||||
/** Non-negative finite number from storage, or 0 for missing/corrupt values. */
|
||||
function readNum(key) {
|
||||
const v = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* (c) Permanent opt-out — true if the user ever clicked "Don't ask again",
|
||||
* on this popover OR on the legacy postcard prompt (whose flag persists in
|
||||
* the zustand `omnivoice.app` blob). A promise made once is kept forever.
|
||||
*/
|
||||
export function isDonationOptedOut() {
|
||||
try {
|
||||
if (localStorage.getItem(LS_OPT_OUT) === '1') return true;
|
||||
} catch {
|
||||
return false; // storage unavailable → nothing persisted, nothing to honor
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORE_KEY);
|
||||
if (raw && JSON.parse(raw)?.state?.optedOut === true) return true;
|
||||
} catch {
|
||||
/* unreadable legacy blob → fall through */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Don't ask again" — terminal. No donation prompt will ever show again. */
|
||||
export function optOutOfDonationMoments() {
|
||||
try {
|
||||
localStorage.setItem(LS_OPT_OUT, '1');
|
||||
} catch {
|
||||
/* storage unavailable — the session cap still silences this launch */
|
||||
}
|
||||
sessionPromptShown = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one genuine value-creation success and decide whether to prompt.
|
||||
*
|
||||
* @param {string} kind what succeeded ('export' | 'dub' | 'audiobook' |
|
||||
* 'batch' | 'clone' | ...) — carried in the event detail.
|
||||
* @param {{ now?: number, random?: () => number }} [opts] injectable clock
|
||||
* (epoch ms) and RNG for deterministic tests.
|
||||
* @returns {{ show: boolean, reason: string, line?: number }}
|
||||
*/
|
||||
export function recordValueMoment(kind, { now = Date.now(), random = Math.random } = {}) {
|
||||
let momentCount;
|
||||
let firstMomentAt;
|
||||
try {
|
||||
momentCount = readNum(LS_MOMENT_COUNT) + 1;
|
||||
firstMomentAt = readNum(LS_FIRST_MOMENT_AT) || now;
|
||||
localStorage.setItem(LS_MOMENT_COUNT, String(momentCount));
|
||||
localStorage.setItem(LS_FIRST_MOMENT_AT, String(firstMomentAt));
|
||||
} catch {
|
||||
return { show: false, reason: 'storage-unavailable' };
|
||||
}
|
||||
|
||||
// Gates, cheapest-first. Counters above are recorded regardless, so the
|
||||
// lifetime history stays truthful even while a gate blocks.
|
||||
if (isDonationOptedOut()) return { show: false, reason: 'opted-out' };
|
||||
if (sessionPromptShown) return { show: false, reason: 'session-cap' };
|
||||
if (momentCount < MIN_LIFETIME_MOMENTS) return { show: false, reason: 'too-few-moments' };
|
||||
|
||||
const lastPromptAt = readNum(LS_LAST_PROMPT_AT);
|
||||
if (lastPromptAt > 0) {
|
||||
if (now - lastPromptAt < PROMPT_COOLDOWN_DAYS * DAY_MS) {
|
||||
return { show: false, reason: 'cooldown' };
|
||||
}
|
||||
} else if (now - firstMomentAt < FIRST_PROMPT_MIN_DAYS * DAY_MS) {
|
||||
return { show: false, reason: 'first-prompt-delay' };
|
||||
}
|
||||
|
||||
if (random() >= PROMPT_PROBABILITY) return { show: false, reason: 'lost-roll' };
|
||||
|
||||
// Eligible. Commit the shown-state BEFORE dispatching so a second success
|
||||
// in the same tick can't double-fire, then rotate through the lines.
|
||||
const line = readNum(LS_PROMPT_COUNT) % DONATE_LINE_COUNT;
|
||||
sessionPromptShown = true;
|
||||
try {
|
||||
localStorage.setItem(LS_LAST_PROMPT_AT, String(now));
|
||||
localStorage.setItem(LS_PROMPT_COUNT, String(readNum(LS_PROMPT_COUNT) + 1));
|
||||
} catch {
|
||||
/* best effort — the session cap alone still prevents same-run repeats */
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(DONATION_MOMENT_EVENT, { detail: { kind, line } }));
|
||||
}
|
||||
return { show: true, reason: 'shown', line };
|
||||
}
|
||||
|
||||
/** Test-only: clear the once-per-session cap (simulates a fresh launch). */
|
||||
export function _resetDonationSessionForTests() {
|
||||
sessionPromptShown = false;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// donationMoments — exhaustive gate coverage for the footer donation-prompt
|
||||
// eligibility engine. New module, so no fail-before regression pair; instead
|
||||
// every gate, boundary day, the opt-out paths (own + legacy postcard), the
|
||||
// session cap, and the localStorage round-trip are pinned deterministically
|
||||
// via the injected clock + RNG.
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
recordValueMoment,
|
||||
optOutOfDonationMoments,
|
||||
isDonationOptedOut,
|
||||
_resetDonationSessionForTests,
|
||||
MIN_LIFETIME_MOMENTS,
|
||||
PROMPT_COOLDOWN_DAYS,
|
||||
FIRST_PROMPT_MIN_DAYS,
|
||||
PROMPT_PROBABILITY,
|
||||
DONATE_LINE_COUNT,
|
||||
DONATION_MOMENT_EVENT,
|
||||
LS_MOMENT_COUNT,
|
||||
LS_FIRST_MOMENT_AT,
|
||||
LS_LAST_PROMPT_AT,
|
||||
LS_PROMPT_COUNT,
|
||||
LS_OPT_OUT,
|
||||
} from './donationMoments';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const T0 = Date.UTC(2026, 0, 1); // arbitrary fixed epoch
|
||||
const win = () => 0; // always wins the 25% roll
|
||||
const lose = () => 0.99; // always loses the roll
|
||||
|
||||
/** Record `n` silent moments at `now` (roll always lost → never prompts). */
|
||||
function burnMoments(n, now = T0) {
|
||||
for (let i = 0; i < n; i++) recordValueMoment('export', { now, random: lose });
|
||||
}
|
||||
|
||||
/** Storage state where the NEXT winning moment is fully eligible at `now`. */
|
||||
function seedEligible(now) {
|
||||
localStorage.setItem(LS_MOMENT_COUNT, String(MIN_LIFETIME_MOMENTS));
|
||||
localStorage.setItem(LS_FIRST_MOMENT_AT, String(now - (FIRST_PROMPT_MIN_DAYS + 1) * DAY));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
_resetDonationSessionForTests();
|
||||
});
|
||||
|
||||
describe('gate (a) — lifetime value-moment minimum', () => {
|
||||
it('never prompts brand-new users, even on a winning roll', () => {
|
||||
const res = recordValueMoment('export', { now: T0, random: win });
|
||||
expect(res).toMatchObject({ show: false, reason: 'too-few-moments' });
|
||||
});
|
||||
|
||||
it(`stays silent until ${MIN_LIFETIME_MOMENTS} lifetime moments are recorded`, () => {
|
||||
for (let i = 1; i < MIN_LIFETIME_MOMENTS; i++) {
|
||||
// Spread over many days so no OTHER gate can be the blocker.
|
||||
const res = recordValueMoment('export', { now: T0 + i * 5 * DAY, random: win });
|
||||
expect(res).toMatchObject({ show: false, reason: 'too-few-moments' });
|
||||
}
|
||||
// Moment #MIN is the first that can prompt (first-moment delay long past).
|
||||
const res = recordValueMoment('export', {
|
||||
now: T0 + MIN_LIFETIME_MOMENTS * 5 * DAY,
|
||||
random: win,
|
||||
});
|
||||
expect(res.show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate (b) — first-prompt delay and prompt cooldown', () => {
|
||||
it(`first prompt waits ≥${FIRST_PROMPT_MIN_DAYS} days after the first value-moment`, () => {
|
||||
burnMoments(MIN_LIFETIME_MOMENTS, T0); // all in one sitting
|
||||
const early = recordValueMoment('export', {
|
||||
now: T0 + FIRST_PROMPT_MIN_DAYS * DAY - 1,
|
||||
random: win,
|
||||
});
|
||||
expect(early).toMatchObject({ show: false, reason: 'first-prompt-delay' });
|
||||
// Boundary: exactly N days qualifies.
|
||||
const onTime = recordValueMoment('export', {
|
||||
now: T0 + FIRST_PROMPT_MIN_DAYS * DAY,
|
||||
random: win,
|
||||
});
|
||||
expect(onTime.show).toBe(true);
|
||||
});
|
||||
|
||||
it(`re-prompts only ≥${PROMPT_COOLDOWN_DAYS} days after the last prompt`, () => {
|
||||
seedEligible(T0);
|
||||
expect(recordValueMoment('export', { now: T0, random: win }).show).toBe(true);
|
||||
_resetDonationSessionForTests(); // fresh launch → only the cooldown gates
|
||||
|
||||
const tooSoon = recordValueMoment('export', {
|
||||
now: T0 + PROMPT_COOLDOWN_DAYS * DAY - 1,
|
||||
random: win,
|
||||
});
|
||||
expect(tooSoon).toMatchObject({ show: false, reason: 'cooldown' });
|
||||
|
||||
const onTime = recordValueMoment('export', {
|
||||
now: T0 + PROMPT_COOLDOWN_DAYS * DAY,
|
||||
random: win,
|
||||
});
|
||||
expect(onTime.show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate (c) — permanent opt-out', () => {
|
||||
it('"Don\'t ask again" is terminal, across sessions', () => {
|
||||
optOutOfDonationMoments();
|
||||
expect(localStorage.getItem(LS_OPT_OUT)).toBe('1');
|
||||
expect(isDonationOptedOut()).toBe(true);
|
||||
|
||||
seedEligible(T0);
|
||||
_resetDonationSessionForTests(); // even a brand-new session
|
||||
const res = recordValueMoment('export', { now: T0, random: win });
|
||||
expect(res).toMatchObject({ show: false, reason: 'opted-out' });
|
||||
});
|
||||
|
||||
it('honors the legacy postcard opt-out persisted in the omnivoice.app blob', () => {
|
||||
localStorage.setItem('omnivoice.app', JSON.stringify({ state: { optedOut: true } }));
|
||||
expect(isDonationOptedOut()).toBe(true);
|
||||
seedEligible(T0);
|
||||
const res = recordValueMoment('export', { now: T0, random: win });
|
||||
expect(res).toMatchObject({ show: false, reason: 'opted-out' });
|
||||
});
|
||||
|
||||
it('a corrupt legacy blob is ignored (not treated as opted out)', () => {
|
||||
localStorage.setItem('omnivoice.app', '{definitely not json');
|
||||
expect(isDonationOptedOut()).toBe(false);
|
||||
seedEligible(T0);
|
||||
expect(recordValueMoment('export', { now: T0, random: win }).show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate (d) — once per app session', () => {
|
||||
it('never prompts twice in one session, even past the cooldown', () => {
|
||||
seedEligible(T0);
|
||||
expect(recordValueMoment('export', { now: T0, random: win }).show).toBe(true);
|
||||
// Same session, clock pushed WAY past the cooldown: still capped.
|
||||
const res = recordValueMoment('export', {
|
||||
now: T0 + 10 * PROMPT_COOLDOWN_DAYS * DAY,
|
||||
random: win,
|
||||
});
|
||||
expect(res).toMatchObject({ show: false, reason: 'session-cap' });
|
||||
// A fresh session (plus elapsed cooldown) is eligible again.
|
||||
_resetDonationSessionForTests();
|
||||
expect(
|
||||
recordValueMoment('export', { now: T0 + 10 * PROMPT_COOLDOWN_DAYS * DAY, random: win }).show,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate (e) — the random roll', () => {
|
||||
it(`shows only when the roll lands strictly under ${PROMPT_PROBABILITY}`, () => {
|
||||
seedEligible(T0);
|
||||
const lost = recordValueMoment('export', { now: T0, random: () => PROMPT_PROBABILITY });
|
||||
expect(lost).toMatchObject({ show: false, reason: 'lost-roll' });
|
||||
const won = recordValueMoment('export', {
|
||||
now: T0,
|
||||
random: () => PROMPT_PROBABILITY - 0.001,
|
||||
});
|
||||
expect(won.show).toBe(true);
|
||||
});
|
||||
|
||||
it('a lost roll does not burn the session cap or set a cooldown', () => {
|
||||
seedEligible(T0);
|
||||
recordValueMoment('export', { now: T0, random: lose });
|
||||
expect(localStorage.getItem(LS_LAST_PROMPT_AT)).toBeNull();
|
||||
expect(recordValueMoment('export', { now: T0, random: win }).show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state persistence (localStorage round-trip)', () => {
|
||||
it('moment counters survive a "restart" (module session state cleared)', () => {
|
||||
burnMoments(MIN_LIFETIME_MOMENTS - 1, T0);
|
||||
expect(localStorage.getItem(LS_MOMENT_COUNT)).toBe(String(MIN_LIFETIME_MOMENTS - 1));
|
||||
expect(localStorage.getItem(LS_FIRST_MOMENT_AT)).toBe(String(T0));
|
||||
_resetDonationSessionForTests(); // simulate a fresh launch, same storage
|
||||
// One more moment, days later → all gates pass off the PERSISTED history.
|
||||
const res = recordValueMoment('export', {
|
||||
now: T0 + (FIRST_PROMPT_MIN_DAYS + 1) * DAY,
|
||||
random: win,
|
||||
});
|
||||
expect(res.show).toBe(true);
|
||||
expect(localStorage.getItem(LS_MOMENT_COUNT)).toBe(String(MIN_LIFETIME_MOMENTS));
|
||||
});
|
||||
|
||||
it('a shown prompt commits the cooldown anchor and prompt counter', () => {
|
||||
seedEligible(T0);
|
||||
recordValueMoment('export', { now: T0, random: win });
|
||||
expect(localStorage.getItem(LS_LAST_PROMPT_AT)).toBe(String(T0));
|
||||
expect(localStorage.getItem(LS_PROMPT_COUNT)).toBe('1');
|
||||
});
|
||||
|
||||
it('corrupt counters degrade to zero instead of throwing', () => {
|
||||
localStorage.setItem(LS_MOMENT_COUNT, 'garbage');
|
||||
localStorage.setItem(LS_FIRST_MOMENT_AT, 'NaN');
|
||||
const res = recordValueMoment('export', { now: T0, random: win });
|
||||
expect(res).toMatchObject({ show: false, reason: 'too-few-moments' });
|
||||
expect(localStorage.getItem(LS_MOMENT_COUNT)).toBe('1'); // repaired
|
||||
expect(localStorage.getItem(LS_FIRST_MOMENT_AT)).toBe(String(T0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt event + line rotation', () => {
|
||||
it(`dispatches ${DONATION_MOMENT_EVENT} with kind + rotating line on show only`, () => {
|
||||
const seen = [];
|
||||
const onMoment = (e) => seen.push(e.detail);
|
||||
window.addEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
try {
|
||||
recordValueMoment('export', { now: T0, random: win }); // blocked (grace)
|
||||
expect(seen).toHaveLength(0);
|
||||
|
||||
let now = T0;
|
||||
for (let i = 0; i < DONATE_LINE_COUNT + 1; i++) {
|
||||
seedEligible(now);
|
||||
_resetDonationSessionForTests();
|
||||
const res = recordValueMoment('dub', { now, random: win });
|
||||
expect(res.show).toBe(true);
|
||||
expect(res.line).toBe(i % DONATE_LINE_COUNT); // 0,1,2,3,0…
|
||||
now += (PROMPT_COOLDOWN_DAYS + 1) * DAY;
|
||||
}
|
||||
expect(seen).toHaveLength(DONATE_LINE_COUNT + 1);
|
||||
expect(seen[0]).toEqual({ kind: 'dub', line: 0 });
|
||||
expect(seen[DONATE_LINE_COUNT].line).toBe(0); // wrapped around
|
||||
} finally {
|
||||
window.removeEventListener(DONATION_MOMENT_EVENT, onMoment);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a bare {show:false} decision without dispatching when capped', () => {
|
||||
const spy = vi.fn();
|
||||
window.addEventListener(DONATION_MOMENT_EVENT, spy);
|
||||
try {
|
||||
seedEligible(T0);
|
||||
recordValueMoment('export', { now: T0, random: win });
|
||||
recordValueMoment('export', { now: T0, random: win }); // session-capped
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.removeEventListener(DONATION_MOMENT_EVENT, spy);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user