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
2 changes: 2 additions & 0 deletions apps/website/i18n/course-ladder.ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"SAY_PAID_LINE": "Клуб оплачивается звёздами Telegram в боте, там же цена. Уже в клубе? Та же кнопка выдаст ключ.",
"SAY_PAID_PRESS": "Вступить в клуб в Telegram",
"SAY_PAID_LATER": "Оплата здесь в рублях, долларах или крипте ещё не сделана.",
"SAY_CLUB_TITLE": "Клуб открывает остальную лестницу",
"SAY_CLUB_LINE": "Четвёртый и все следующие курсы открываются с клубом: наша единственная подписка, оплачивается звёздами Telegram в боте, там же цена. Часть каждого платежа возвращается вам токенами.",
"SAY_CHECKING": "Проверяем ключ...",
"SAY_KEY_BAD": "Этот ключ не открыл курс ({0}). Нажмите кнопку ещё раз — бот выдаст новый.",
"SAY_SHARED": "Ваши пройденные уроки видит автор курса."
Expand Down
14 changes: 14 additions & 0 deletions apps/website/specs/ladder/course-ladder.t27
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ 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.";
; 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.
pub const SAY_CLUB_TITLE : str = "The club opens the rest of the ladder";
pub const SAY_CLUB_LINE : str = "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 you as tokens.";
pub const SAY_CHECKING : str = "Checking your key...";
pub const SAY_KEY_BAD : str = "This key did not open the course ({0}). Press the button again for a fresh one.";
pub const SAY_SHARED : str = "Your finished lessons are shared with the course author.";
Expand All @@ -53,3 +58,12 @@ test the_order_and_the_keys_stay_in_the_browser {
assert KEYS_KEY == "t27-course-keys";
assert OPEN_KEY != KEYS_KEY;
}

; The gate's words answer "why is this locked"; the teaser answers "what is next"
; for a reader mid-course. If they ever become the same sentence, one of the two
; pages is saying the wrong thing.
test the_teaser_speaks_for_itself_not_for_the_gate {
assert SAY_CLUB_TITLE != SAY_PAID_TITLE;
assert SAY_CLUB_LINE != SAY_PAID_LINE;
assert SAY_CLUB_LINE != SAY_LADDER;
}
4 changes: 4 additions & 0 deletions apps/website/src/lib/courseLadder.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const LADDER = {
"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.",
"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 you as tokens.",
"CHECKING": "Checking your key...",
"KEY_BAD": "This key did not open the course ({0}). Press the button again for a fresh one.",
"SHARED": "Your finished lessons are shared with the course author."
Expand All @@ -40,6 +42,8 @@ export const LADDER = {
"PAID_LINE": "Клуб оплачивается звёздами Telegram в боте, там же цена. Уже в клубе? Та же кнопка выдаст ключ.",
"PAID_PRESS": "Вступить в клуб в Telegram",
"PAID_LATER": "Оплата здесь в рублях, долларах или крипте ещё не сделана.",
"CLUB_TITLE": "Клуб открывает остальную лестницу",
"CLUB_LINE": "Четвёртый и все следующие курсы открываются с клубом: наша единственная подписка, оплачивается звёздами Telegram в боте, там же цена. Часть каждого платежа возвращается вам токенами.",
"CHECKING": "Проверяем ключ...",
"KEY_BAD": "Этот ключ не открыл курс ({0}). Нажмите кнопку ещё раз — бот выдаст новый.",
"SHARED": "Ваши пройденные уроки видит автор курса."
Expand Down
30 changes: 27 additions & 3 deletions apps/website/src/pages/Course.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ function framesOf(l: CourseLesson): Frame[] {
return [main, ...spec, ...also]
}

function Lesson({ C, lesson, lang, say, done, toggle, shared }: { C: CourseT; lesson: CourseLesson; lang: Lang; say: Say; done: string[]; toggle: (id: string) => void; shared?: string }) {
function Lesson({ C, lesson, lang, say, done, toggle, shared, ladder }: { C: CourseT; lesson: CourseLesson; lang: Lang; say: Say; done: string[]; toggle: (id: string) => void; shared?: string; ladder: ReturnType<typeof useLadder> }) {
const LESSONS = C.lessons as readonly CourseLesson[]
const TOTAL = LESSONS.length
const frames = useMemo(() => framesOf(lesson), [lesson])
Expand Down Expand Up @@ -362,6 +362,8 @@ function Lesson({ C, lesson, lang, say, done, toggle, shared }: { C: CourseT; le
)}
</nav>

{lesson.n === 1 && !ladder.checking && !ladder.inClub ? <ClubTeaser C={C} lang={lang} /> : null}

<CourseMap C={C} lang={lang} say={say} done={done} current={lesson.id} />
<p className="course-progress is-foot">{fmt(say.PROGRESS, done.length, TOTAL)}</p>
<CourseNotes C={C} lang={lang} say={say} id={lesson.id} shared={shared} />
Expand Down Expand Up @@ -434,7 +436,29 @@ function useLadder(C: CourseT, allDone: string[]) {
const open = () => setOpened((o) => openCourse(C.id, o, valid))
// The key a lesson report travels with: the paid one while it holds, else the bot's.
const reportKey = valid.paid ? keys.paid : valid.bot ? keys.bot : undefined
return { state, open, checking, bad, reportKey }
return { state, open, checking, bad, reportKey, inClub: valid.paid }
}

/**
* The club, named where a reader actually is. The gate below says the club only when a course
* is locked, and a reader who never climbs past their first open course never meets it at all;
* the bot's thirty-day funnel counted 1 club card against 56 new people. So lesson one carries
* the teaser: its words are the ladder spec's (SAY_CLUB_*), its button is the same start link
* the gate uses, and a reader already holding a paid key sees nothing (owner, 2026-10-13).
*/
function ClubTeaser({ C, lang }: { C: CourseT; lang: Lang }) {
const say = LADDER.say[lang] as LadderSay
return (
<section className="course-hero course-ladder">
<h2 className="course-title">{say.CLUB_TITLE}</h2>
<p className="course-lead">{say.CLUB_LINE}</p>
<div className="course-hero-actions">
<a className="course-btn is-primary" href={startLink(STEP.paid, C.route)} target="_blank" rel="noopener noreferrer">
{say.PAID_PRESS}
</a>
</div>
</section>
)
}

function LadderGate({ C, lang, ladder }: { C: CourseT; lang: Lang; ladder: ReturnType<typeof useLadder> }) {
Expand Down Expand Up @@ -512,7 +536,7 @@ export default function Course() {
<CourseMap C={C} lang={lang} say={say} done={done} current={lesson?.id} />
</>
) : lesson ? (
<Lesson C={C} lesson={lesson} lang={lang} say={say} done={done} toggle={mark} shared={shared} />
<Lesson C={C} lesson={lesson} lang={lang} say={say} done={done} toggle={mark} shared={shared} ladder={ladder} />
) : (
<Overview C={C} lang={lang} say={say} done={done} shared={shared} />
)}
Expand Down
Loading