// calc-payg.jsx — the pay-per-use shop on /calculator (PAYG_ENABLED). // Every price comes from the server (/api/payg/catalog, /api/payg/quote); this // file never holds a price - it only shows what the server returns. Every async button shows // three states: working / done / failed (with the real reason). const PAYG_FMT = (n) => (n == null || isNaN(n)) ? '—' : ('€' + (Math.round(n * 100) % 100 === 0 ? Math.round(n) : Number(n).toFixed(2))); // VAT words follow STRIPE_TAX_BEHAVIOR via the catalog (never hard-coded here). const PAYG_VAT_ADD = (cat) => (cat && cat.tax_behavior === 'inclusive') ? ' incl. VAT' : ' + VAT'; const PAYG_EU = ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']; const paygGuessCountry = () => { try { const l = (navigator.language || '').split('-')[1]; if (l && l.length === 2) return l.toUpperCase(); } catch (e) {} return ''; }; // The total THIS buyer pays (Stripe Tax for their country) next to the listed price. function PaygVatPrice({ sku, minutes, net, cat }) { const [country, setCountry] = React.useState(paygGuessCountry()); const [biz, setBiz] = React.useState(false); const [vatId, setVatId] = React.useState(''); const [d, setD] = React.useState(null); React.useEffect(() => { if (!sku || net == null || !(cat && cat.vat_display)) { setD(null); return; } const qs = new URLSearchParams({ sku, country: biz ? '' : country }); if (minutes) qs.set('minutes', minutes); if (biz && vatId.trim().length >= 4) qs.set('vat_id', vatId.trim()); let live = true; paygJSON('/api/payg/price-display?' + qs.toString()).then((r) => { if (live) setD(r.ok ? r.data : null); }); return () => { live = false; }; }, [sku, minutes, net, country, biz, vatId]); const note = (cat && cat.price_note) || 'excl. VAT'; let main; if (d && d.known && d.reverse_charge) { main =
{PAYG_FMT(net)} excl. VAT - reverse charge (business with a VAT number).
; } else if (d && d.known && d.tax_cents > 0) { main =
{PAYG_FMT(d.total_cents / 100)} incl. {d.rate_pct != null ? (Math.round(d.rate_pct * 100) / 100) + '% ' : ''}VAT for {d.country || 'your country'} · {PAYG_FMT(d.net_cents / 100)} {note}
; } else { main =
{PAYG_FMT(net)} {note}. VAT for your country is added at checkout.
; } if (!(cat && cat.vat_display)) return null; return (
{main}
{!biz ? ( ) : ( setVatId(e.target.value)} style={{ padding: '6px 8px', borderRadius: 6, border: '1px solid var(--line-soft)' }} /> )}
); } async function paygJSON(url, opts) { const r = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {})); let d = {}; try { d = await r.json(); } catch (e) { d = {}; } return { status: r.status, ok: r.ok, data: d }; } function PaygStatusLine({ state, okText, errText }) { if (!state || state === 'idle') return null; if (state === 'working') return
Working on it…
; if (state === 'done') return
{okText || 'Done.'}
; return
{errText || 'Something went wrong.'}
; } function PaygAccountGate({ account, resumeSel }) { const [email, setEmail] = React.useState(''); const [st, setSt] = React.useState('idle'); const [msg, setMsg] = React.useState(''); const next = '/calculator' + (resumeSel ? ('?sku=' + encodeURIComponent(resumeSel.sku)) : ''); const stash = () => { try { localStorage.setItem('payg_resume', JSON.stringify({ sel: resumeSel, ts: Date.now() })); } catch (e) {} }; const sendLink = async () => { if (!email.trim()) { setSt('failed'); setMsg('Please enter your e-mail address.'); return; } stash(); setSt('working'); setMsg(''); try { const r = await paygJSON('/api/payg/magic-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim(), next }) }); if (r.ok) { setSt('done'); setMsg(r.data.message || 'Check your inbox.'); } else { setSt('failed'); setMsg(r.data.message || 'We could not send the link.'); } } catch (e) { setSt('failed'); setMsg('No connection - please try again.'); } }; const sso = (account && account.sso) || {}; return (
Sign in to buy
A free account is all you need - it is where your video lands, and it lets us handle consent and the AI label properly. No subscription.
{sso.google ? Continue with Google : null} {sso.microsoft ? Continue with Microsoft : null}
setEmail(e.target.value)} />
); } function PaygOrderView({ orderId, account }) { const [o, setO] = React.useState(null); const [err, setErr] = React.useState(''); const [rf, setRf] = React.useState('idle'); const [rfMsg, setRfMsg] = React.useState(''); const [nudge, setNudge] = React.useState(null); const load = React.useCallback(async () => { try { const r = await paygJSON('/api/payg/orders/' + orderId); if (r.ok) { setO(r.data); setErr(''); } else { setErr(r.status === 401 ? 'Please sign in to see this order.' : (r.data.message || 'Order not found.')); } } catch (e) { setErr('No connection - retrying…'); } }, [orderId]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { if (o && ['delivered', 'credited', 'refunded', 'expired'].includes(o.status)) return; const iv = setInterval(load, 4000); return () => clearInterval(iv); }, [o, load]); React.useEffect(() => { if (o && o.status === 'delivered') paygJSON('/api/payg/nudge').then((r) => { if (r.ok && r.data.show) setNudge(r.data); }).catch(() => {}); }, [o && o.status]); const refund = async () => { setRf('working'); setRfMsg(''); try { const r = await paygJSON('/api/payg/orders/' + orderId + '/refund', { method: 'POST' }); if (r.ok) { setRf('done'); setRfMsg('Refund started - it usually shows on your card within 5-10 working days.'); load(); } else { setRf('failed'); setRfMsg(r.data.message || 'The refund could not be started.'); } } catch (e) { setRf('failed'); setRfMsg('No connection - please try again.'); } }; if (err && !o) return

{err}

{account && !account.signed_in ? : null}
; if (!o) return
Loading your order…
; const s = o.status; const title = { created: 'Waiting for your payment', paid: 'Payment received', rendering: 'Your video is being made', delivered: 'Your video is ready', credited: 'We could not finish your video', refunded: 'Refunded', expired: 'This payment page has expired' }[s] || s; return (
Order #{o.order_id} · {o.label} · {PAYG_FMT(o.price_eur)} {o.price_note || 'excl. VAT'}

{title}

{(s === 'paid' || s === 'rendering') ?

This usually takes a few minutes. You can close this page - we e-mail you when it is ready, and it will be in My Videos.

: null} {s === 'delivered' ? (
{o.result_url ? : null}

The video carries the "AI-generated" label required by the EU AI Act (article 50).

Open My Videos
) : null} {s === 'credited' ? (

Something went wrong on our side. You have not been charged for it: the {PAYG_FMT(o.price_eur)} is on your MyAvatar account as {o.tokens} credits, ready for your next video.

Prefer the money back on your card instead?

) : null} {s === 'refunded' ?

The payment has been refunded to your card.

: null} {s === 'created' || s === 'expired' ? Back to the shop : null} {nudge ? (
You have bought {nudge.count} videos in the last 30 days for {PAYG_FMT(nudge.spent_eur)}. {nudge.saving_eur > 0 ? The {nudge.plan} plan ({PAYG_FMT(nudge.plan_price_eur)}/month) would have saved you {PAYG_FMT(nudge.saving_eur)}. See plans : Plans start at {PAYG_FMT(nudge.plans_from_eur)}/month and add your own actor library, brand kit and distribution. See plans}
) : null}
); } function PaygShop() { const params = new URLSearchParams(window.location.search); const orderId = params.get('order'); const [cat, setCat] = React.useState(null); const [catErr, setCatErr] = React.useState(''); const [account, setAccount] = React.useState(null); const [pres, setPres] = React.useState(null); const startSku = params.get('sku') || 'talking_head_30'; const [kind, setKind] = React.useState(startSku.indexOf('translate') === 0 ? 'translation' : 'talking_head'); const [sku, setSku] = React.useState(startSku); const [who, setWho] = React.useState('house'); const [actorId, setActorId] = React.useState(null); const [likeness, setLikeness] = React.useState(false); const [script, setScript] = React.useState(''); const [videoUrl, setVideoUrl] = React.useState(''); const [lang, setLang] = React.useState('English'); const [tq, setTq] = React.useState(null); const [tqSt, setTqSt] = React.useState('idle'); const [tqMsg, setTqMsg] = React.useState(''); const [buySt, setBuySt] = React.useState('idle'); const [buyMsg, setBuyMsg] = React.useState(''); const [needLogin, setNeedLogin] = React.useState(false); React.useEffect(() => { paygJSON('/api/payg/catalog').then((r) => { if (r.ok) setCat(r.data); else setCatErr(r.data.message || 'The shop is not available right now.'); }).catch(() => setCatErr('No connection.')); paygJSON('/api/payg/account').then((r) => setAccount(r.data)).catch(() => setAccount({ signed_in: false, sso: {} })); paygJSON('/api/payg/presenters').then((r) => { if (r.ok) { setPres(r.data); setActorId(r.data.default_id); } }).catch(() => {}); try { const st = JSON.parse(localStorage.getItem('payg_resume') || 'null'); if (st && st.sel && Date.now() - (st.ts || 0) < 30 * 60 * 1000) { const s = st.sel; if (s.sku) { setSku(s.sku); setKind(s.sku.indexOf('translate') === 0 ? 'translation' : 'talking_head'); } if (s.params) { if (s.params.script) setScript(s.params.script); if (s.params.actor_id) setActorId(s.params.actor_id); if (s.params.presenter) setWho(s.params.presenter); if (s.params.video_url) setVideoUrl(s.params.video_url); if (s.params.target_language) setLang(s.params.target_language); } } localStorage.removeItem('payg_resume'); } catch (e) {} }, []); if (orderId) return (
{ window.location.href = '/calculator'; }} />
); const prod = (k) => (cat && cat.products || []).find((p) => p.sku === k) || null; const cur = prod(sku); const twinSetup = prod('twin_setup'); const ownTwin = pres && pres.own_twin; const params_ = () => kind === 'translation' ? { video_url: videoUrl.trim(), target_language: lang } : { presenter: who, actor_id: actorId, script: script, likeness_confirmed: likeness }; const priceShown = kind === 'translation' ? (tq ? tq.price_eur : null) : (cur ? cur.price_eur : null); const checkTranslation = async () => { setTqSt('working'); setTqMsg(''); setTq(null); try { const r = await paygJSON('/api/payg/quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sku, video_url: videoUrl.trim() }) }); if (r.ok) { setTq(r.data); setTqSt('done'); setTqMsg(r.data.minutes + ' min (' + Math.round(r.data.duration_s) + ' s) at ' + PAYG_FMT(r.data.per_minute_eur) + ' per started minute' + (r.data.min_order_applied ? ' - minimum order ' + PAYG_FMT(cat.min_order_eur) : '')); } else if (r.status === 401) { setNeedLogin(true); setTqSt('failed'); setTqMsg('Sign in first so we can read your video.'); } else { setTqSt('failed'); setTqMsg(r.data.message || 'Could not price that video.'); } } catch (e) { setTqSt('failed'); setTqMsg('No connection - please try again.'); } }; const buy = async (buySku, buyParams) => { setBuySt('working'); setBuyMsg(''); try { const r = await paygJSON('/api/payg/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sku: buySku, params: buyParams }) }); if (r.ok && r.data.url) { setBuySt('done'); setBuyMsg('Opening the secure payment page…'); window.location.href = r.data.url; return; } if (r.status === 401) { setNeedLogin(true); setBuySt('failed'); setBuyMsg('Sign in to continue - your choices are kept.'); return; } setBuySt('failed'); setBuyMsg(r.data.message || 'Could not open the payment page.'); } catch (e) { setBuySt('failed'); setBuyMsg('No connection - nothing was charged. Please try again.'); } }; const tile = (k, title, desc, sel, onClick, locked) => ( ); return (
window.scrollTo({ top: 0, behavior: 'smooth' })} />
Buy one video · no subscription

Pick it, pay once, get it in minutes

Fixed price, shown before you pay. {window.MAVat ? null : ((cat && cat.tax_behavior === 'inclusive') ? 'Prices include VAT.' : 'Prices exclude VAT, which is added at checkout for your country.')}

{catErr ?
{catErr}
: null}
1What do you need?
{tile('th', 'Talking-head video', 'A presenter speaks your message to camera - up to 30 s or up to 60 s.', kind === 'talking_head', () => { setKind('talking_head'); setSku('talking_head_30'); })} {tile('tr', 'Translate my video', 'Same voice, new language, lips re-synced. Up to ' + ((prod('translate_standard') || {}).max_minutes || 5) + ' minutes.', kind === 'translation', () => { setKind('translation'); setSku('translate_standard'); })} {tile('fs', 'Short film', 'Cinematic scenes with a cast.', false, null, 'Coming soon')} {tile('f35', 'Longer film', 'About 35 seconds, several scenes.', false, null, 'Coming soon')}
{kind === 'talking_head' ? (
2How long?
{['talking_head_30', 'talking_head_60'].map((k) => { const p = prod(k); return tile(k, (p ? (p.length_label || p.label) : k), p ? : '…', sku === k, () => setSku(k)); })}
3Who presents it?
{tile('house', 'A House Cast presenter', 'Included in the price.', who === 'house', () => setWho('house'))} {tile('own', 'My own digital twin', ownTwin && ownTwin.ready ? ('Your twin: ' + (ownTwin.name || 'ready')) : 'Needs your trained twin and recorded consent.', who === 'own', () => setWho('own'), ownTwin && ownTwin.ready ? null : 'Set up once first')}
{who === 'house' && pres ? ( ) : null} {who === 'own' && ownTwin && ownTwin.ready ? ( ) : null} {!(ownTwin && ownTwin.ready) && twinSetup && twinSetup.for_sale ? (
Your own twin: one-time setup {PAYG_FMT(twinSetup.price_eur)}{PAYG_VAT_ADD(cat)}} />, reused on every future video.{' '}
) : null}
4Your script - or just describe the idea
{script.length}/2000
) : (
2Quality
{['translate_standard', 'translate_premium'].map((k) => { const p = prod(k); return tile(k, k === 'translate_standard' ? 'Standard lip-sync' : 'Premium lip-sync', p ? : '…', sku === k, () => { setSku(k); setTq(null); setTqSt('idle'); }); })}
3Your video and the new language
{ setVideoUrl(e.target.value); setTq(null); setTqSt('idle'); }} />
Lip-sync needs one person on screen. With nobody or several people only the voice is translated - the price stays the same and the result says so.
)}
Your price
{PAYG_FMT(priceShown)}{PAYG_VAT_ADD(cat)}} />
{priceShown != null && !window.MAVat ? : null} {kind === 'translation' && !tq ?
Add your video link to see the exact price.
: null}
One fixed price, paid once - no subscription
Labelled as AI-generated (EU AI Act art. 50)
If it fails on our side you are not charged: credit, or your money back
{(needLogin || (account && !account.signed_in)) ? : null}
VAT is added at checkout for your country. Business? Add your VAT number there.
); } Object.assign(window, { PaygShop, PaygOrderView, PaygAccountGate });