Skip to content

Commit 75c9005

Browse files
authored
feat: split site into public landing (/) + game Mini App (/game) (#279)
Serve a crawler-friendly investor/user landing at the root and move the Vue Mini App under /game, so cubeworlds.club previews and SEO work while the game keeps running inside Telegram. - src/landing/: static landing (index.html) + root-served tonconnect manifest and logo; screen gallery of every Mini App screen and state - vite base '/game/'; vue-router uses import.meta.env.BASE_URL - server.ts serves landing at /, frontend/dist at /game/, with a path-split SPA fallback (/api -> 404, /game* -> game, else -> landing) in both dev (Vite appType 'custom') and prod - bot Mini App launch URLs (help, removed-commands, menu button) now open ${WEB_APP_URL}/game; WEB_APP_URL itself stays root for the API/webhook - menu button update is now guarded + idempotent: getChatMenuButton, compare URL/label, set only when different, log the / -> /game migration Requires a one-time BotFather change of the Direct Link Mini App URL to https://cubeworlds.club/game (not settable via Bot API).
1 parent 0154252 commit 75c9005

12 files changed

Lines changed: 909 additions & 23 deletions

File tree

src/bot/features/help.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const feature = composer.chatType('private')
1111
feature.command(
1212
'help',
1313
logHandle('command-help'),
14-
buildHelpCommandHandler({ webAppUrl: config.WEB_APP_URL }),
14+
// The Mini App lives under /game (root serves the public landing).
15+
buildHelpCommandHandler({ webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game` }),
1516
)
1617

1718
export { composer as helpFeature }

src/bot/features/removed-commands.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ const composer = new Composer<Context>()
88

99
const feature = composer.chatType('private')
1010

11-
const handle = buildRemovedCommandsHandler({ webAppUrl: config.WEB_APP_URL })
11+
// The Mini App lives under /game (root serves the public landing).
12+
const handle = buildRemovedCommandsHandler({
13+
webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game`,
14+
})
1215

1316
feature.on('message:text', logHandle('removed-command'), async (ctx, next) => {
1417
const text = ctx.message?.text ?? ''

src/bot/handlers/commands/sync-commands-core.test.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable test/no-import-node-test */
22
import type { BotCommandScope } from '@grammyjs/types'
3-
import type { BotApiLike } from '#root/bot/handlers/commands/sync-commands-core'
3+
import type { BotApiLike, ChatMenuButton } from '#root/bot/handlers/commands/sync-commands-core'
44
import assert from 'node:assert/strict'
55
import { test } from 'node:test'
66
import {
@@ -15,7 +15,9 @@ interface SetMyCommandsCall {
1515
options?: { language_code?: string, scope?: BotCommandScope }
1616
}
1717

18-
function makeApiStub() {
18+
function makeApiStub(
19+
currentMenuButton: ChatMenuButton = { type: 'default' },
20+
) {
1921
const setMyCommandsCalls: SetMyCommandsCall[] = []
2022
const setMyDescriptionCalls: Array<{ description: string, options?: { language_code?: string } }> = []
2123
const setMyShortDescriptionCalls: Array<{ short_description: string, options?: { language_code?: string } }> = []
@@ -33,6 +35,7 @@ function makeApiStub() {
3335
setChatMenuButton: async (options) => {
3436
setChatMenuButtonCalls.push(options)
3537
},
38+
getChatMenuButton: async () => currentMenuButton,
3639
}
3740
return {
3841
api,
@@ -160,18 +163,69 @@ test('syncBotCommands fans description and short-description across locales', as
160163
})
161164
})
162165

163-
test('buildSetMenuButton calls setChatMenuButton with a web_app button at the configured URL', async () => {
164-
const stub = makeApiStub()
165-
const set = buildSetMenuButton({ webAppUrl: 'https://app.example', label: 'Open App' })
166+
test('buildSetMenuButton sets the web_app button when none is configured yet', async () => {
167+
const stub = makeApiStub({ type: 'default' })
168+
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })
166169

167-
await set(stub.api)
170+
const result = await set(stub.api)
168171

169172
assert.equal(stub.setChatMenuButtonCalls.length, 1)
170173
assert.deepEqual(stub.setChatMenuButtonCalls[0], {
171174
menu_button: {
172175
type: 'web_app',
173176
text: 'Open App',
174-
web_app: { url: 'https://app.example' },
177+
web_app: { url: 'https://app.example/game' },
175178
},
176179
})
180+
assert.deepEqual(result, { changed: true, previousUrl: null, url: 'https://app.example/game' })
181+
})
182+
183+
test('buildSetMenuButton updates when the existing web_app URL differs (/ → /game)', async () => {
184+
const stub = makeApiStub({
185+
type: 'web_app',
186+
text: 'Open App',
187+
web_app: { url: 'https://app.example' },
188+
})
189+
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })
190+
191+
const result = await set(stub.api)
192+
193+
assert.equal(stub.setChatMenuButtonCalls.length, 1)
194+
assert.deepEqual(result, {
195+
changed: true,
196+
previousUrl: 'https://app.example',
197+
url: 'https://app.example/game',
198+
})
199+
})
200+
201+
test('buildSetMenuButton is a no-op when the URL and label already match', async () => {
202+
const stub = makeApiStub({
203+
type: 'web_app',
204+
text: 'Open App',
205+
web_app: { url: 'https://app.example/game' },
206+
})
207+
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })
208+
209+
const result = await set(stub.api)
210+
211+
assert.equal(stub.setChatMenuButtonCalls.length, 0)
212+
assert.deepEqual(result, {
213+
changed: false,
214+
previousUrl: 'https://app.example/game',
215+
url: 'https://app.example/game',
216+
})
217+
})
218+
219+
test('buildSetMenuButton updates when only the label differs', async () => {
220+
const stub = makeApiStub({
221+
type: 'web_app',
222+
text: 'Old Label',
223+
web_app: { url: 'https://app.example/game' },
224+
})
225+
const set = buildSetMenuButton({ webAppUrl: 'https://app.example/game', label: 'Open App' })
226+
227+
const result = await set(stub.api)
228+
229+
assert.equal(stub.setChatMenuButtonCalls.length, 1)
230+
assert.equal(result.changed, true)
177231
})

src/bot/handlers/commands/sync-commands-core.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,17 @@ export interface BotApiLike {
1616
setChatMenuButton: (options: {
1717
menu_button: { type: 'web_app', text: string, web_app: { url: string } }
1818
}) => Promise<unknown>
19+
getChatMenuButton: () => Promise<ChatMenuButton>
1920
}
2021

22+
// The subset of Telegram's MenuButton union we need to read back. A web_app
23+
// button carries the URL/label we compare against; the other variants (default
24+
// button, commands) carry no URL, so any of them means "not yet pointing at us".
25+
export type ChatMenuButton =
26+
| { type: 'web_app', text: string, web_app: { url: string } }
27+
| { type: 'default' }
28+
| { type: 'commands' }
29+
2130
export type TranslateFn = (locale: string, key: string) => string
2231

2332
export interface SyncCommandsDependencies {
@@ -114,14 +123,38 @@ export interface SetMenuButtonDependencies {
114123
label: string
115124
}
116125

126+
export interface MenuButtonResult {
127+
/** Whether Telegram was actually updated (false = already correct). */
128+
changed: boolean
129+
/** The web_app URL before this run, or null if no web_app button was set. */
130+
previousUrl: string | null
131+
/** The desired web_app URL. */
132+
url: string
133+
}
134+
117135
export function buildSetMenuButton(deps: SetMenuButtonDependencies) {
118-
return async function setMenuButton(api: Pick<BotApiLike, 'setChatMenuButton'>): Promise<void> {
136+
return async function setMenuButton(
137+
api: Pick<BotApiLike, 'setChatMenuButton' | 'getChatMenuButton'>,
138+
): Promise<MenuButtonResult> {
139+
const current = await api.getChatMenuButton()
140+
const previous
141+
= current.type === 'web_app'
142+
? { url: current.web_app.url, text: current.text }
143+
: null
144+
145+
// Only touch Telegram when the URL or label actually differs — keeps deploys
146+
// idempotent and lets the caller log the / → /game migration when it happens.
147+
if (previous && previous.url === deps.webAppUrl && previous.text === deps.label) {
148+
return { changed: false, previousUrl: previous.url, url: deps.webAppUrl }
149+
}
150+
119151
await api.setChatMenuButton({
120152
menu_button: {
121153
type: 'web_app',
122154
text: deps.label,
123155
web_app: { url: deps.webAppUrl },
124156
},
125157
})
158+
return { changed: true, previousUrl: previous?.url ?? null, url: deps.webAppUrl }
126159
}
127160
}

src/bot/handlers/commands/sync-commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export const syncBotCommands = buildSyncBotCommands({
1515
})
1616

1717
export const setMenuButton = buildSetMenuButton({
18-
webAppUrl: config.WEB_APP_URL,
18+
// The Mini App lives under /game (root serves the public landing).
19+
webAppUrl: `${config.WEB_APP_URL.replace(/\/$/, '')}/game`,
1920
label: i18n.t(DEFAULT_LOCALE, 'menu_button.label'),
2021
})
2122

src/frontend/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import 'element-plus/dist/index.css'
1212
import './style.css'
1313

1414
const router = createRouter({
15-
history: createWebHistory(),
15+
// Base is '/game/' in prod (see vite.config.ts base); route paths like '/mint'
16+
// stay base-relative, so the beforeEach gate comparisons are unaffected.
17+
history: createWebHistory(import.meta.env.BASE_URL),
1618
routes: vueRoutes,
1719
})
1820

src/frontend/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { defineConfig } from 'vite'
77

88
// https://vitejs.dev/config/
99
export default defineConfig({
10+
// The game is served under /game (the crawler-friendly landing owns the root).
11+
// This prefixes every emitted asset URL and feeds vue-router's base via
12+
// import.meta.env.BASE_URL. The Telegram Mini App URL must point at /game.
13+
base: '/game/',
1014
resolve: {
1115
alias: {
1216
'@': fileURLToPath(new URL('./src', import.meta.url)),

0 commit comments

Comments
 (0)