

Loading the page…
Sponsor Bot's panel in English and Persian: logical sides, one rule for arrows, digits and dates, text that keeps its own direction, Telegram's bidi, and nine things that broke right to left.
Written with AI: Claude Opus 5.5, through Claude
9 lessons learned building it
The product
Sponsor Bot — Telegram Uploader Bot with Forced Channel Join, Ad Shop and Admin PanelStudio plan · Full-stack project


The product
Studio plan · Full-stack project
A self-hosted Telegram uploader bot: content links ask visitors to join your sponsor channels first. Sponsor campaigns with reports, an ad shop paid in USDT or TRX on TRON, statistics, broadcasts and a team, in one admin panel in English and Persian. One-script install.
Sponsor Bot is a self-hosted platform for Telegram uploader bots with a forced-join gate, sponsor campaigns and an ad shop (its engine, the queues and the TRON payments, are in an article of their own). Its panel, about 23,500 lines over 30 pages of Next.js, Tailwind CSS and Radix, was built in English and then made bilingual: English left to right, Persian right to left, each person choosing theirs. The work went page by page in nine phases, and each was checked in both languages at desktop and phone width before the next. The live demo has the language switch on its sign-in page and in the user menu.
This article is for anyone, person or AI, adding a right-to-left language to an existing React app: how the language is chosen and carried, what changes for layout, type, digits and dates, what must stay left to right, how the server and a Telegram bot speak the language too, and nine things that went wrong on the way, each with its fix.
The choice is a cookie, sb_lang (en or fa), set from the user menu, Settings → Appearance and the sign-in pages. The server draws <html lang dir> from it, so a change reloads the page, and Radix's direction provider gets it too, so menus, selects and submenus open the right way. Until someone picks, the panel is English whatever the browser's language: the first version guessed from the browser, and Ramin chose English for everyone who hasn't picked. The addresses don't change: it's an admin panel, nothing for search engines.
Words sit next to the markup that shows them, typed so a missing Persian string fails the build:
export type Copy<T> = Record<Lang, T>;
/** For words kept in pairs where they're used: `const t = inLang(lang)`, then `t("Bots", "رباتها")`. */
export function inLang(lang: Lang) {
return <T>(en: T, fa: T): T => (lang === "fa" ? fa : en);
}Sentences with a number in them are functions, because the two languages build them differently. English makes the noun plural; Persian keeps it singular after a number and puts the words around it in another order:
Every physical side class became a logical one: ml-/mr- to ms-/me-, pl-/pr- to ps-/pe-, left-/right- insets to inset-s-/inset-e-, text-left to text-start, borders and rounded corners likewise. That was 262 classes in 57 files, and English renders exactly as before. Centring (left-1/2 -translate-x-1/2) stays physical, since it is symmetric anyway.
Arrows and chevrons that mean "onward" (next, continue, open, sign out) have to point the other way. Rather than a class on every icon, one rule at the end of the stylesheet turns them all:
Adding Tailwind's rtl:-scale-x-100 to one of these icons on top of it would turn it back again.
Some things read left to right whatever the page: e-mail, password, URL and number fields (the shared Input sets dir="ltr" for those types), codes and keys, @usernames, addresses, transaction hashes, versions and file names. Charts and the day's time track stay left to right too, since time runs left to right like a clock's numbers; their Persian labels are isolated so they keep their own word order. Inside a sentence, two helpers wrap a piece of text in Unicode's directional isolates:
U+2066 (left-to-right isolate) and U+2067 (right-to-left isolate) open a run that the surrounding text can't reorder, and U+2069 closes it, so "@sponsor_bot" in a Persian sentence keeps its "@" in front.
On elements, dir="ltr" does the same, and that is where the first trap was:
The Persian face is Vazirmatn, placed after the Latin faces in every font stack: Latin letters keep their own faces on Persian pages, and thanks to the fonts' unicode-range, only the Arabic-script file is ever fetched, and only for a page with Persian on it. Persian letters join one to the next, so the rules differ:
No letter-spacing (the tracked labels would pull joined letters apart), no italics (a slanted Persian word is only a distorted one), and a little more line height for titles set at 1, which would clip Persian's dots.
Both are one line each, and both were Ramin's calls after seeing the pages:
Persian pages use Western digits (0–9): Persian ones broke the panel's rhythm and its alignment. Dates are Gregorian with Persian month names ("11 اکتبر 2026"). Every formatter reads its locale from these, fa-IR-u-ca-gregory-nu-latn, and setting the Persian entries to "persian" and "arabext" brings the Solar Hijri calendar (مهر ۱۴۰۵) and Persian digits back everywhere; that code stays, and the browser's own persian calendar data was checked for every month of 1390–1420, leap years included. Month grids start on Saturday and run right to left in Persian, the week grid reads clicks and drags from its right edge so a drag picks the days under the pointer, and weekdays stay ISO numbers (1 Monday … 7 Sunday) in the data: only their order on screen changes.
The API answers in the panel's language: a small middleware first in the pipeline reads the same cookie into an AsyncLocal, so the error handler after it follows too, and every message the panel can show, about 330 sentences, is written as a pair. Sentences the server stores and shows later (why an ad's time failed, a payment check's note) are stored in English whoever's request made them, and translated when they are read, so a Persian owner and an English admin each read the same note in their own language.
Each bot has a language of its own, chosen when it is connected, with Persian defaults for all 80 of its texts. A Persian bot writes dates ("چهارشنبه 14 اکتبر · 10:00"), durations and lists in Persian. Telegram brought two more things to handle:
The lookbehind skips a @ or / that is part of a word, an address or a link, and one that already has a mark.
Each phase was checked in headless Chrome at 1440 px and 390 px, in both languages, before it was committed; the typed copy means a missing Persian string fails tsc; and 19 server tests hold the API to answering in Persian with the cookie and in English without, and a Persian bot to greeting, gating and answering its uploader in Persian.
The order that worked: make the language a cookie the server reads, so <html lang dir> is right in the first byte; sweep every physical side class to a logical one before translating anything; decide early what stays left to right, and give it dir="ltr" on the innermost element or an isolate; type the copy so a missing translation can't ship; decide digits and calendar with the people who will read it, in one place; and check every page in both directions at phone width, with a viewport screenshot.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #86, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.
Behind the build
How Sponsor Bot runs many Telegram bots on one PostgreSQL database, no Redis: inbox, outbox and leases, a forced-join gate, TRON payment checks, and a load test to 200 updates a second.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork17 min readAI
19 lessons


Performance · Media Folder — macOS-Style Draggable Window Desktop for React & Framer Motion
How Media Folder drags windows with no React renders, no layout and no paint, and seven traps that cost frames: SVG animations, two videos at once, glass blur, first-reveal uploads.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork7 min readAI
7 lessons


Behind the build · Lightfall — Crypto & Web3 Hero Section for React with WebGL Background
How Lightfall draws up to 160 light lanes in one shader pass, why its WebGL runs in a Web Worker (Lighthouse 70 to 98), and eight traps in GLSL, layout shift, timing and contrast.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork9 min readAI
8 lessons
the CSS build (Lightning CSS, under Next.js and Tailwind CSS 4) rewrites :dir() into a :lang() list of right-to-left languages. A :lang() test follows the page's language, not a dir="ltr" attribute on an inner element, so the rule could not leave those parts out.
attribute selectors, which the build leaves alone: [dir="rtl"] :is(…):not([dir="ltr"] *).
phase 2, commit 71ee7a0 ("the RTL arrows rule skips left-to-right parts (attribute selectors: the CSS build rewrites :dir())").
the margin stays on an outer element in the page's direction, and dir="ltr" goes on an inner span around the text alone.
phase 3, commit 04c127e ("usernames keep their gap on the page's side (dir=ltr on an inner span)").
the unit is an inline-block, which the line treats as one piece, so it keeps its own margin where the line's direction puts it: <span className="ms-1.5 inline-block …">{stat.unit}</span> on the Overview, Campaigns, Rate cards, Calendar and Orders.
phase 4, commit 14f166e ("Stat units after a number no longer run into it on Persian pages").
what people type keeps its own direction. A <bdi> around it where it wraps, so its punctuation stays at its own end; dir="auto" on an element cut short with an ellipsis, so the cut falls at its end whichever language it is in. The panel has 46 <bdi> and 28 dir="auto".
phase 5, commit c28f921 (the API's messages in a <bdi>), and docs/languages.md, "What people type keeps its own direction".
the field stays mono for what people paste, and on Persian pages its placeholder uses the sans face: className={cn("font-mono text-[13px]", lang === "fa" && "placeholder:font-sans")}.
phase 5, commit c28f921; the order page's refund field and the payment settings' key field.
first, the dot was drawn as a rounder bullet from a one-glyph font on Persian pages. When Persian pages moved to Western digits (Ramin's call, for rhythm and alignment), the confusion was gone and so was the font: "·" is "·" again in both languages. With Persian digits back on, the separator needs the same care.
phase 6, commit 43649ef (the bullet font); phase 7, commit b436598 (Western digits, the font removed).
every Persian default text starts its lines with a Persian word where it can: "قیمت هر جایگاه: 15 USDT", not "15 USDT …". The panel's chat preview does what Telegram does with unicode-bidi: plaintext on each bubble, so the owner sees the real result while rewriting a text.
phase 8, commit 78eddd6; MessageTexts.Fa.cs and the panel's chat preview.
a left-to-right mark (U+200E) goes before a @ or / that starts a mention or command, and around a value that begins in Latin letters; only in Persian bots' messages, so English bots' messages are byte for byte as before.
phase 8, commit 78eddd6 (SettleText and SettleValue in MessageTexts.Fa.cs).
short pages are checked with a viewport screenshot; the page was never the problem.
phase 5, commit c28f921 (docs/languages.md, "A screenshot trap").
slotsFrom: (patterns) => `From ${plural(patterns, "weekly pattern", undefined, "en")} and your own`,
slotsFrom: (patterns) => `از ${count(patterns, "fa")} الگوی هفتگی و جایگاههای خودتان`,[dir="rtl"]
:is(
.lucide-arrow-right,
.lucide-arrow-left,
.lucide-chevron-right,
.lucide-chevron-left,
.lucide-chevrons-right,
.lucide-chevrons-left,
.lucide-arrow-up-right,
.lucide-log-out,
.lucide-log-in
):not([dir="ltr"] *) {
transform: scaleX(-1);
}export function ltr(text: string): string {
return `${String.fromCharCode(0x2066)}${text}${String.fromCharCode(0x2069)}`;
}
/** The other way round: Persian words inside something laid out left to right (a chart's axis), kept in their order. */
export function rtl(text: string): string {
return `${String.fromCharCode(0x2067)}${text}${String.fromCharCode(0x2069)}`;
}html:lang(fa) * {
letter-spacing: normal;
}
html:lang(fa) :is(em, i, .italic) {
font-style: normal;
}
html:lang(fa) .font-display {
line-height: 1.25;
}export const CALENDAR: Record<Lang, "gregory" | "persian"> = { en: "gregory", fa: "gregory" };
export const NUMERALS: Record<Lang, "latn" | "arabext"> = { en: "latn", fa: "latn" };private const char Lrm = '\u200E';
[GeneratedRegex(@"(?<![A-Za-z0-9_/:.@<\u200E])([@/])(?=[A-Za-z0-9_])")]
private static partial Regex MentionOrCommand();
public static string SettleText(string text) => MentionOrCommand().Replace(text, $"{Lrm}$1");
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment