Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/website/i18n/course-ladder.ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Проверяем ключ...",
Expand Down Expand Up @@ -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": "Каждый урок идёт прямо в браузере, с живым виджетом или спецификацией, которую можно запустить. Любые три курса бесплатно, без регистрации.",
Expand Down
22 changes: 20 additions & 2 deletions apps/website/specs/ladder/course-ladder.t27
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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).
Expand Down
28 changes: 26 additions & 2 deletions apps/website/src/lib/courseLadder.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
Expand Down Expand Up @@ -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.",
Expand All @@ -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 коротких уроков в браузере, в каждом живой виджет или спека, которую можно запустить. Любые три — бесплатно, без регистрации и почты; остальные открывает клуб.",
Expand Down
46 changes: 40 additions & 6 deletions apps/website/src/lib/courseLadder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,51 @@ export async function checkKey(k: string): Promise<KeyCheck> {
}
}

/** 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<TonTransfer> | 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<Offer> {
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' }
}
}

Expand All @@ -138,7 +172,7 @@ export async function readOrder(id: string): Promise<Order> {
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' }
}
Expand Down
35 changes: 35 additions & 0 deletions apps/website/src/pages/Course.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading