diff --git a/apps/website/i18n/course-ladder.ru.json b/apps/website/i18n/course-ladder.ru.json index 515e1271d2..11f737a852 100644 --- a/apps/website/i18n/course-ladder.ru.json +++ b/apps/website/i18n/course-ladder.ru.json @@ -11,7 +11,7 @@ "SAY_PAID_TITLE": "Этот курс открывается с клубом", "SAY_PAID_LINE": "Клуб оплачивается звёздами Telegram в боте, там же цена. Уже в клубе? Та же кнопка выдаст ключ.", "SAY_PAID_PRESS": "Вступить в клуб в Telegram", - "SAY_PAID_LATER": "Оплата здесь в рублях, долларах или крипте ещё не сделана.", + "SAY_PAID_LATER": "Оплата здесь картой в долларах пока не сделана.", "SAY_CLUB_TITLE": "Клуб открывает остальную лестницу", "SAY_CLUB_LINE": "Четвёртый и все следующие курсы открываются с клубом: наша единственная подписка, оплачивается звёздами Telegram в боте, там же цена. Часть каждого платежа возвращается на ваш баланс в боте — на генерации.", "SAY_CHECKING": "Проверяем ключ...", @@ -42,6 +42,18 @@ "SAY_PAY_SLOW": "Оплата ещё не подтверждена. Если вы оплатили, страница продолжает проверять; можно обновить её позже.", "SAY_PAY_DONE": "Оплата получена. Добро пожаловать в клуб.", "SAY_PAY_OR_BOT": "Или оплатить звёздами Telegram в нашем боте", + "SAY_TON_LINE": "Или оплатите в TON из любого кошелька: без карты и без Telegram. Сумма — цена клуба по текущему курсу TON.", + "SAY_TON_EMAIL": "Email, чтобы клуб был вашим на любом устройстве", + "SAY_TON_PRESS": "Оплатить {0} TON", + "SAY_TON_SEND": "Отправьте ровно {0} TON на этот адрес с этим комментарием. По комментарию мы находим ваш платёж.", + "SAY_TON_ADDRESS": "Адрес", + "SAY_TON_AMOUNT": "Сумма", + "SAY_TON_COMMENT": "Комментарий", + "SAY_TON_COPY": "Скопировать", + "SAY_TON_COPIED": "Скопировано", + "SAY_TON_OPEN": "Открыть в кошельке", + "SAY_TON_UNTIL": "Сумма действует до {0}. Страница сама откроет курс, когда платёж придёт.", + "SAY_TON_EXPIRED": "Заказ TON истёк, а платёж не пришёл. Создайте новый; если вы уже отправили TON, напишите нам в боте и укажите комментарий.", "SAY_MORE_TITLE": "Другие курсы", "SAY_PROMO_TITLE": "Три курса бесплатно, без регистрации", "SAY_PROMO_LINE": "Каждый урок идёт прямо в браузере, с живым виджетом или спецификацией, которую можно запустить. Любые три курса бесплатно, без регистрации.", diff --git a/apps/website/specs/ladder/course-ladder.t27 b/apps/website/specs/ladder/course-ladder.t27 index 8ef21e58f1..85d54ac51e 100644 --- a/apps/website/specs/ladder/course-ladder.t27 +++ b/apps/website/specs/ladder/course-ladder.t27 @@ -13,7 +13,8 @@ ; What is honest here: the lesson words are in this site's bundle and on its share pages, so ; the ladder orders the reader's way, it does not hide the words (CONTENT_HIDDEN in the rules). ; Paying on the site in roubles goes through the render (GET/POST /api/course/checkout) once -; it has a price set; until then, and in dollars or crypto always, the club is paid in the bot. +; it has a price set; until then the club is paid in the bot. Any reader may pay in TON on the +; site once the render has a wallet and a TON rate; dollars by card are paid in the bot. ; ASCII only (L3), English only (LANG-EN). ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -40,7 +41,7 @@ pub const SAY_BOT_SHARES : str = "From this step on, the course author sees whic pub const SAY_PAID_TITLE : str = "This course opens with the club"; pub const SAY_PAID_LINE : str = "The club is paid in Telegram Stars in the bot, which shows the price. Already in the club? The same button hands you the key."; pub const SAY_PAID_PRESS : str = "Join the club in Telegram"; -pub const SAY_PAID_LATER : str = "Paying here in roubles, dollars or crypto is not built yet."; +pub const SAY_PAID_LATER : str = "Paying here by card in dollars is not built yet."; ; The teaser a reader meets INSIDE an open course (lesson one, below the pager): the gate ; above names the club only when a course is locked, and most readers never climb that far. ; No price here either -- the bot's card says it, same honesty as SAY_PAID_LINE. @@ -61,6 +62,23 @@ pub const SAY_PAY_WAITING : str = "Waiting for the payment to be confirmed..."; pub const SAY_PAY_SLOW : str = "The payment is not confirmed yet. If you paid, this page keeps checking; you can also reload it later."; pub const SAY_PAY_DONE : str = "Payment received. Welcome to the club."; pub const SAY_PAY_OR_BOT : str = "Or pay in Telegram Stars in our bot"; +; Paying in TON on the site (owner, 2026-10-13: "or with crypto"). Any language: the render +; answers GET /api/course/checkout with ton.on and the amount at the current TON rate, and +; the order holds that amount for an hour. The comment is how the render finds the payment. +; Only TON is taken here, never $TRI: the token is not sold (golden-chain-international +; FACTS.md s.6). +pub const SAY_TON_LINE : str = "Or pay in TON from any wallet: no card and no Telegram. The amount is the club price at the current TON rate."; +pub const SAY_TON_EMAIL : str = "Email, so the club is yours on any device"; +pub const SAY_TON_PRESS : str = "Pay {0} TON"; +pub const SAY_TON_SEND : str = "Send exactly {0} TON to this address with this comment. The comment is how we find your payment."; +pub const SAY_TON_ADDRESS : str = "Address"; +pub const SAY_TON_AMOUNT : str = "Amount"; +pub const SAY_TON_COMMENT : str = "Comment"; +pub const SAY_TON_COPY : str = "Copy"; +pub const SAY_TON_COPIED : str = "Copied"; +pub const SAY_TON_OPEN : str = "Open in a wallet"; +pub const SAY_TON_UNTIL : str = "The amount holds until {0}. This page opens the course by itself once the payment arrives."; +pub const SAY_TON_EXPIRED : str = "This TON order ran out before a payment arrived. Start a new one; if you already sent TON, write to us in the bot with the comment."; ; The catalog (#/courses), the header link and the counter of free courses left: a reader ; should know what a press will do before pressing it (owner, 2026-10-13: the navigation ; was too hard to follow). diff --git a/apps/website/src/lib/courseLadder.generated.ts b/apps/website/src/lib/courseLadder.generated.ts index 524b723033..c094041d7a 100644 --- a/apps/website/src/lib/courseLadder.generated.ts +++ b/apps/website/src/lib/courseLadder.generated.ts @@ -21,7 +21,7 @@ export const LADDER = { "PAID_TITLE": "This course opens with the club", "PAID_LINE": "The club is paid in Telegram Stars in the bot, which shows the price. Already in the club? The same button hands you the key.", "PAID_PRESS": "Join the club in Telegram", - "PAID_LATER": "Paying here in roubles, dollars or crypto is not built yet.", + "PAID_LATER": "Paying here by card in dollars is not built yet.", "CLUB_TITLE": "The club opens the rest of the ladder", "CLUB_LINE": "Every course from the fourth on opens with the club: our one subscription, paid in Telegram Stars in the bot, which shows the price. Part of every payment returns to your bot balance, to spend on generations in the bot.", "PAY_LINE": "The club opens every course for {0} days. One payment in roubles, no auto-renewal, no Telegram needed.", @@ -34,6 +34,18 @@ export const LADDER = { "PAY_SLOW": "The payment is not confirmed yet. If you paid, this page keeps checking; you can also reload it later.", "PAY_DONE": "Payment received. Welcome to the club.", "PAY_OR_BOT": "Or pay in Telegram Stars in our bot", + "TON_LINE": "Or pay in TON from any wallet: no card and no Telegram. The amount is the club price at the current TON rate.", + "TON_EMAIL": "Email, so the club is yours on any device", + "TON_PRESS": "Pay {0} TON", + "TON_SEND": "Send exactly {0} TON to this address with this comment. The comment is how we find your payment.", + "TON_ADDRESS": "Address", + "TON_AMOUNT": "Amount", + "TON_COMMENT": "Comment", + "TON_COPY": "Copy", + "TON_COPIED": "Copied", + "TON_OPEN": "Open in a wallet", + "TON_UNTIL": "The amount holds until {0}. This page opens the course by itself once the payment arrives.", + "TON_EXPIRED": "This TON order ran out before a payment arrived. Start a new one; if you already sent TON, write to us in the bot with the comment.", "COURSES": "All courses", "CATALOG_TITLE": "Courses", "CATALOG_LEAD": "Every course is 27 short lessons in the browser, each with a live widget or a spec you can run. Pick any three and they are free, with no sign-up and no email; the club opens the rest.", @@ -72,7 +84,7 @@ export const LADDER = { "PAID_TITLE": "Этот курс открывается с клубом", "PAID_LINE": "Клуб оплачивается звёздами Telegram в боте, там же цена. Уже в клубе? Та же кнопка выдаст ключ.", "PAID_PRESS": "Вступить в клуб в Telegram", - "PAID_LATER": "Оплата здесь в рублях, долларах или крипте ещё не сделана.", + "PAID_LATER": "Оплата здесь картой в долларах пока не сделана.", "CLUB_TITLE": "Клуб открывает остальную лестницу", "CLUB_LINE": "Четвёртый и все следующие курсы открываются с клубом: наша единственная подписка, оплачивается звёздами Telegram в боте, там же цена. Часть каждого платежа возвращается на ваш баланс в боте — на генерации.", "PAY_LINE": "Клуб открывает все курсы на {0} дней. Один платёж в рублях, без автопродления, без Telegram.", @@ -85,6 +97,18 @@ export const LADDER = { "PAY_SLOW": "Оплата ещё не подтверждена. Если вы оплатили, страница продолжает проверять; можно обновить её позже.", "PAY_DONE": "Оплата получена. Добро пожаловать в клуб.", "PAY_OR_BOT": "Или оплатить звёздами Telegram в нашем боте", + "TON_LINE": "Или оплатите в TON из любого кошелька: без карты и без Telegram. Сумма — цена клуба по текущему курсу TON.", + "TON_EMAIL": "Email, чтобы клуб был вашим на любом устройстве", + "TON_PRESS": "Оплатить {0} TON", + "TON_SEND": "Отправьте ровно {0} TON на этот адрес с этим комментарием. По комментарию мы находим ваш платёж.", + "TON_ADDRESS": "Адрес", + "TON_AMOUNT": "Сумма", + "TON_COMMENT": "Комментарий", + "TON_COPY": "Скопировать", + "TON_COPIED": "Скопировано", + "TON_OPEN": "Открыть в кошельке", + "TON_UNTIL": "Сумма действует до {0}. Страница сама откроет курс, когда платёж придёт.", + "TON_EXPIRED": "Заказ TON истёк, а платёж не пришёл. Создайте новый; если вы уже отправили TON, напишите нам в боте и укажите комментарий.", "COURSES": "Все курсы", "CATALOG_TITLE": "Курсы", "CATALOG_LEAD": "Каждый курс — 27 коротких уроков в браузере, в каждом живой виджет или спека, которую можно запустить. Любые три — бесплатно, без регистрации и почты; остальные открывает клуб.", diff --git a/apps/website/src/lib/courseLadder.ts b/apps/website/src/lib/courseLadder.ts index b3fd0b7e20..d6c9627127 100644 --- a/apps/website/src/lib/courseLadder.ts +++ b/apps/website/src/lib/courseLadder.ts @@ -103,17 +103,51 @@ export async function checkKey(k: string): Promise { } } -/** The render's on-site checkout: on only once it has a rouble price and Robokassa set. */ -export type Offer = { on: boolean; rub?: number; days?: number } -export type Order = { ok: boolean; status?: 'pending' | 'paid'; key?: string; until?: string; reason?: string } +/** + * The render's on-site checkout. Roubles (on) only once it has Robokassa set; TON (ton.on) only + * once it has a wallet and a TON rate. The two switch on independently. + */ +export type Offer = { on: boolean; rub?: number; days?: number; ton?: { on: boolean; amount?: number } } +/** Where and how much to send for a TON order, as the render answers it. */ +export type TonTransfer = { address: string; amount: number; nano: string; comment: string; link: string; expiresAt?: string } +export type Order = { ok: boolean; status?: 'pending' | 'paid' | 'expired'; key?: string; until?: string; reason?: string; ton?: TonTransfer } + +function readTonTransfer(t: unknown): TonTransfer | undefined { + const o = t as Partial | null + if (!o || typeof o.address !== 'string' || typeof o.comment !== 'string' || typeof o.nano !== 'string') return undefined + if (typeof o.amount !== 'number' || !(o.amount > 0)) return undefined + // Only a TON transfer link is ever handed to the wallet button. + if (typeof o.link !== 'string' || !o.link.startsWith('ton://transfer/')) return undefined + return { address: o.address, amount: o.amount, nano: o.nano, comment: o.comment, link: o.link, expiresAt: typeof o.expiresAt === 'string' ? o.expiresAt : undefined } +} export async function checkoutOffer(): Promise { try { const r = await fetch(`${LADDER.renderBase}/api/course/checkout`, { credentials: 'omit' }) const body = (await r.json()) as Offer - return body && body.on === true && Number.isInteger(body.rub) && (body.rub ?? 0) > 0 ? body : { on: false } + const rubOn = Boolean(body && body.on === true && Number.isInteger(body.rub) && (body.rub ?? 0) > 0) + const t = body?.ton + const ton = t && t.on === true && typeof t.amount === 'number' && t.amount > 0 ? { on: true, amount: t.amount } : { on: false } + return rubOn ? { on: true, rub: body.rub, days: body.days, ton } : { on: false, days: body?.days, ton } } catch { - return { on: false } + return { on: false, ton: { on: false } } + } +} + +/** A TON order for the club: the address, the amount and the comment to send it with. */ +export async function startTonCheckout(email: string, route: string, lang: string): Promise<{ ok: boolean; order?: string; ton?: TonTransfer; reason?: string }> { + try { + const r = await fetch(`${LADDER.renderBase}/api/course/checkout`, { + method: 'POST', + credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, route, lang, from: readPromo(), source: readSource(), pay: 'ton' }), + }) + const body = await r.json() + const ton = readTonTransfer(body?.ton) + return body && body.ok === true && typeof body.order === 'string' && ton ? { ok: true, order: body.order, ton } : { ok: false, reason: body?.reason ?? `http_${r.status}` } + } catch { + return { ok: false, reason: 'unreachable' } } } @@ -138,7 +172,7 @@ export async function readOrder(id: string): Promise { try { const r = await fetch(`${LADDER.renderBase}/api/course/checkout/order?id=${encodeURIComponent(id)}`, { credentials: 'omit' }) const body = (await r.json()) as Order - return body && typeof body.ok === 'boolean' ? body : { ok: false, reason: 'unreadable' } + return body && typeof body.ok === 'boolean' ? { ...body, ton: readTonTransfer(body.ton) } : { ok: false, reason: 'unreadable' } } catch { return { ok: false, reason: 'unreachable' } } diff --git a/apps/website/src/pages/Course.css b/apps/website/src/pages/Course.css index 1a22c4709a..432a2082b8 100644 --- a/apps/website/src/pages/Course.css +++ b/apps/website/src/pages/Course.css @@ -756,3 +756,38 @@ .course-shelf .course-catalog { margin-top: 20px; } + +/* A TON order: amount, address and comment, each one press to copy. */ +.course-ton-rows { + margin: 12px 0 16px; + display: grid; + gap: 8px; +} +.course-ton-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 12px; + align-items: baseline; +} +.course-ton-row dt { + color: #a6a6a6; +} +.course-ton-row dd { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + min-width: 0; +} +.course-ton-row code { + overflow-wrap: anywhere; + color: #fff; +} +.course-ton-row button.course-link { + background: none; + border: 0; + padding: 0; + cursor: pointer; + font: inherit; +} diff --git a/apps/website/src/pages/Course.tsx b/apps/website/src/pages/Course.tsx index b27365a969..89ca7468ed 100644 --- a/apps/website/src/pages/Course.tsx +++ b/apps/website/src/pages/Course.tsx @@ -8,7 +8,7 @@ import CourseShelf from '../components/CourseShelf' import { shareTagged } from '../lib/shareTags' import { COURSE, COURSES, type Course as CourseT, type CourseLesson } from '../lib/course.generated' import { LADDER, type LadderSay } from '../lib/courseLadder.generated' -import { KEY_PARAM, ORDER_PARAM, STEP, notePromo, checkKey, checkoutOffer, courseState, freeLeft, openCourse, readOrder, startCheckout, type Offer, readKeys, readOpened, reportDone, saveKeys, startLink, type Keys, type Opened } from '../lib/courseLadder' +import { KEY_PARAM, ORDER_PARAM, STEP, notePromo, checkKey, checkoutOffer, courseState, freeLeft, openCourse, readOrder, startCheckout, startTonCheckout, type Offer, type TonTransfer, readKeys, readOpened, reportDone, saveKeys, startLink, type Keys, type Opened } from '../lib/courseLadder' import './Course.css' // Every word, the order of the courses and lessons and the widget each one opens @@ -410,7 +410,9 @@ function useLadder(C: CourseT, allDone: string[]) { const [offer, setOffer] = useState({ on: false }) // Back from Robokassa with ?order=: 'waiting' while the render has not heard the payment yet, // 'slow' after half a minute of it, 'done' once the key is in hand. - const [paying, setPaying] = useState<'idle' | 'waiting' | 'slow' | 'done'>('idle') + const [paying, setPaying] = useState<'idle' | 'waiting' | 'slow' | 'done' | 'expired'>('idle') + // A TON order in progress: what to send, where, with which comment. + const [ton, setTon] = useState(null) useEffect(() => notePromo(search), [search]) @@ -430,10 +432,22 @@ function useLadder(C: CourseT, allDone: string[]) { setPaying('waiting') void (async () => { // The render books the payment when Robokassa tells it, not when this browser comes back: - // ask until it has, for up to ten minutes. - while (live && Date.now() - started < 600_000) { + // ask until it has, for up to ten minutes. A TON order holds its amount for an hour and the + // render still looks a quarter of an hour after, so a TON order is asked for that long. + let limit = 600_000 + while (live && Date.now() - started < limit) { const r = await readOrder(order) if (!live) return + if (r.ok && r.status === 'expired') { + setTon(null) + setPaying('expired') + navigate(pathname, { replace: true }) + return + } + if (r.ton) { + setTon(r.ton) + limit = 75 * 60_000 + } if (r.ok && r.status === 'paid' && r.key) { const held: Keys = { ...readKeys(), paid: r.key } saveKeys(held) @@ -441,11 +455,12 @@ function useLadder(C: CourseT, allDone: string[]) { setKeys(held) setValid(now) setOpened((o) => openCourse(C.id, o, now)) + setTon(null) setPaying('done') navigate(pathname, { replace: true }) return } - if (Date.now() - started > 30_000) setPaying('slow') + if (!r.ton && Date.now() - started > 30_000) setPaying('slow') await new Promise((res) => setTimeout(res, 2500)) } })() @@ -502,7 +517,16 @@ function useLadder(C: CourseT, allDone: string[]) { if (r.ok && r.url) window.location.assign(r.url) return r } - return { state, open, checking, bad, reportKey, inClub: valid.paid, left: freeLeft(opened), offer, paying, pay } + const payTon = async (email: string, lang: string) => { + const r = await startTonCheckout(email, C.route, lang) + if (r.ok && r.order && r.ton) { + setTon(r.ton) + // The order rides in the address, so a reload keeps waiting for the same payment. + navigate(`${pathname}?${ORDER_PARAM}=${encodeURIComponent(r.order)}`, { replace: true }) + } + return r + } + return { state, open, checking, bad, reportKey, inClub: valid.paid, left: freeLeft(opened), offer, paying, pay, ton, payTon } } /** @@ -531,29 +555,32 @@ function ClubTeaser({ C, lang }: { C: CourseT; lang: Lang }) { * The club paid here, without the bot (owner, 2026-10-13): one field, one button, the price on * the button. The email only goes to Robokassa for its receipt; the key comes back to this page. */ -function PayForm({ lang, ladder }: { lang: Lang; ladder: ReturnType }) { +function PayForm({ lang, ladder, rub, ton }: { lang: Lang; ladder: ReturnType; rub: boolean; ton: boolean }) { const say = LADDER.say[lang] as LadderSay const [email, setEmail] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - const submit = async (e: React.FormEvent) => { - e.preventDefault() + const go = async (via: 'rub' | 'ton') => { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { setError(say.PAY_BAD_EMAIL) return } setBusy(true) setError(null) - const r = await ladder.pay(email.trim(), lang) - if (!r.ok) { - setBusy(false) - setError(fmt(say.PAY_ERROR, r.reason ?? 'unknown')) - } + const r = via === 'ton' ? await ladder.payTon(email.trim(), lang) : await ladder.pay(email.trim(), lang) + // Roubles leave for Robokassa; TON stays here and shows where to send. Either way a + // failure gives the buttons back. + if (!r.ok || via === 'ton') setBusy(false) + if (!r.ok) setError(fmt(say.PAY_ERROR, r.reason ?? 'unknown')) + } + const submit = (e: React.FormEvent) => { + e.preventDefault() + void go(rub ? 'rub' : 'ton') } return ( -
void submit(e)}> +
setEmail(e.target.value)} /> - + {rub ? ( + + ) : null} + {ton ? ( + + ) : null}
{error ?

{error}

: null} -

{say.PAY_SAFE}

+ {rub ?

{say.PAY_SAFE}

: null} + {ton ?

{say.TON_LINE}

: null}
) } +/** A TON order waiting for its payment: the three things to send, each one press to copy. */ +function TonWait({ lang, t }: { lang: Lang; t: TonTransfer }) { + const say = LADDER.say[lang] as LadderSay + const [copied, setCopied] = useState(null) + const copy = (what: string, text: string) => { + void navigator.clipboard?.writeText(text).then(() => setCopied(what), () => undefined) + } + const until = t.expiresAt ? new Date(t.expiresAt) : null + const rows: [string, string, string][] = [ + ['amount', say.TON_AMOUNT, String(t.amount)], + ['address', say.TON_ADDRESS, t.address], + ['comment', say.TON_COMMENT, t.comment], + ] + return ( +
+

{fmt(say.TON_SEND, t.amount)}

+
+ {rows.map(([what, label, value]) => ( +
+
{label}
+
+ {value} + +
+
+ ))} +
+ + {until && !Number.isNaN(until.getTime()) ? ( +

+ {fmt(say.TON_UNTIL, until.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' }))} +

+ ) : null} +
+ ) +} + function LadderGate({ C, lang, ladder }: { C: CourseT; lang: Lang; ladder: ReturnType }) { const say = LADDER.say[lang] as LadderSay const { state, checking, bad } = ladder const step = state.step // Roubles for Russian-language readers only (owner, 2026-10-13); everyone else keeps the bot's Stars. const rub = ladder.offer.on && lang === 'ru' + // TON for every language, once the render has a wallet and a rate (owner, 2026-10-13: "or with crypto"). + const ton = ladder.offer.ton?.on === true return (

{fmt(say.RUNG, state.rung)}

@@ -596,12 +676,15 @@ function LadderGate({ C, lang, ladder }: { C: CourseT; lang: Lang; ladder: Retur

{ladder.left === 1 ? say.LAST_FREE : fmt(say.FREE_SLOT, state.rung)}

) : null} {bad ?

{fmt(say.KEY_BAD, bad)}

: null} - {ladder.paying === 'waiting' || ladder.paying === 'slow' ? ( + {ladder.paying === 'expired' ?

{say.TON_EXPIRED}

: null} + {(ladder.paying === 'waiting' || ladder.paying === 'slow') && ladder.ton ? ( + + ) : ladder.paying === 'waiting' || ladder.paying === 'slow' ? (

{ladder.paying === 'slow' ? say.PAY_SLOW : say.PAY_WAITING}

- ) : step === STEP.paid && rub && !checking ? ( - + ) : step === STEP.paid && (rub || ton) && !checking ? ( + ) : null}
{checking && step !== STEP.free ? ( @@ -610,7 +693,7 @@ function LadderGate({ C, lang, ladder }: { C: CourseT; lang: Lang; ladder: Retur - ) : step === STEP.paid && rub ? ( + ) : step === STEP.paid && (rub || ton) ? ( {say.PAY_OR_BOT} @@ -633,7 +716,7 @@ function LadderGate({ C, lang, ladder }: { C: CourseT; lang: Lang; ladder: Retur

) : null} {step !== STEP.free ?

{say.BOT_SHARES}

: null} - {step === STEP.paid && !rub ?

{say.PAID_LATER}

: null} + {step === STEP.paid && !rub && !ton ?

{say.PAID_LATER}

: null}

{say.LADDER}

)