From 6b3586de92f830f1c1aec059904bf63b329cb911 Mon Sep 17 00:00:00 2001 From: SomiVista Date: Wed, 2 Sep 2026 18:59:51 +0300 Subject: [PATCH 1/2] feat(ui): rebuild Start a session on subturtle-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds /sessions/new — the page, the Gemini StartNew entry point, the shared StartLiveSessionForm and VoicePicker — in the st- design language. All script logic is carried over verbatim: the dataProvider bundle controller, GEMINI_VOICES, the formData shape, isFormValid over the form's exposed selectionError/randomError, the freemium and voice-cap gates, and pickPhraseIds + encodeSessionRequest. Layout follows the design: a two-column grid with the bundle picker and coach card on the left and a sticky summary panel on the right, carrying the selection, the voice-minute or freemium meter, and the single Start button. Adds StSegmentedControl and StProgressBar to subturtle-ui, ported from the design system's SegmentedControl.jsx and ProgressBar.jsx. Both are real design-system primitives and both are used again by Session history. Three design elements are deliberately not shipped, for want of data: - The "Due today" / "Never practised" bundle chips. Leitner due-counts live per phrase in a separate database and a bundle carries no last-practised marker, so only Recent / All are wired. - "Repeat your last session". Its only source is the list-live-sessions RPC, which is Learner+ gated and throws for the free tier — not cheap, and broken for the tier most likely to be on this page. - The voice "Preview" pill. CoachVoice reserves avatarUrl as null and carries no audio; there is no preview endpoint. The bundle pick tile, coach voice tile and filter chip stay page-local: they are single-screen specialisations with no design-system counterpart, following the call already made for InlineNotice. Co-Authored-By: Claude Opus 5 (1M context) --- .../bundle/StartLiveSessionForm.vue | 341 ++++++------- frontend/components/live/VoicePicker.vue | 130 ++--- .../liveSession/gemini/StartNew.vue | 483 ++++++++++++------ frontend/locales/en.json | 36 +- frontend/pages/sessions/new.vue | 48 +- ui/scripts/build-icons.mjs | 9 +- ui/src/elements/StProgressBar.vue | 57 +++ ui/src/elements/StSegmentedControl.vue | 74 +++ ui/src/icon/icons.generated.ts | 7 + ui/src/index.ts | 3 + 10 files changed, 761 insertions(+), 427 deletions(-) create mode 100644 ui/src/elements/StProgressBar.vue create mode 100644 ui/src/elements/StSegmentedControl.vue diff --git a/frontend/components/bundle/StartLiveSessionForm.vue b/frontend/components/bundle/StartLiveSessionForm.vue index 500b9910..373ba8b6 100644 --- a/frontend/components/bundle/StartLiveSessionForm.vue +++ b/frontend/components/bundle/StartLiveSessionForm.vue @@ -1,215 +1,188 @@ diff --git a/frontend/components/live/VoicePicker.vue b/frontend/components/live/VoicePicker.vue index 3ee11f45..ebfa78b7 100644 --- a/frontend/components/live/VoicePicker.vue +++ b/frontend/components/live/VoicePicker.vue @@ -1,74 +1,74 @@ diff --git a/frontend/components/liveSession/gemini/StartNew.vue b/frontend/components/liveSession/gemini/StartNew.vue index 1917bab3..324d0ca7 100644 --- a/frontend/components/liveSession/gemini/StartNew.vue +++ b/frontend/components/liveSession/gemini/StartNew.vue @@ -1,171 +1,350 @@ diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 463eec87..c74455ce 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -199,7 +199,16 @@ "tap-label": "Tap to talk", "hands-free-tooltip": "Hands-free: keep the microphone open for the whole session.", "hands-free-label": "Hands-free" - } + }, + "coach": { + "title": "Your coach", + "subtitle-voice": "Pick the voice you want to hear.", + "subtitle-text": "A text session is written only — no voice to pick." + }, + "phrases": { + "title": "Phrases this session" + }, + "pick-randomly": "Pick randomly instead" }, "subscription": { "title": "Subscription", @@ -492,7 +501,30 @@ "total-tokens": "Total Tokens", "total-cost": "Total Cost (USD)", "duration": "Duration", - "start-new-session": "Start New Session" + "start-new-session": "Start New Session", + "overline": "LIVE SESSIONS", + "start-new-subtitle": "Pick a bundle, choose a coach, and talk your phrases into memory.", + "no-bundles": { + "title": "No bundles to practise yet", + "description": "A live session runs on a phrase bundle. Save a few phrases from YouTube, Netflix or any page and one will be waiting here." + }, + "pick-bundle": { + "title": "Pick a bundle", + "subtitle": "The coach will practise the phrases inside it with you.", + "phrase-count": "{count} phrase | {count} phrases", + "scope-recent": "Recent", + "scope-all": "All" + }, + "summary": { + "title": "Your session", + "bundle": "Bundle", + "fallback": "Falls back to", + "phrases": "Phrases", + "random-count": "{n} at random", + "free-sessions": "Free sessions", + "free-chats": "Free text chats", + "pick-first": "Pick a bundle to start." + } }, "profile": { "profile": "Profile", diff --git a/frontend/pages/sessions/new.vue b/frontend/pages/sessions/new.vue index 29805690..0086bc20 100644 --- a/frontend/pages/sessions/new.vue +++ b/frontend/pages/sessions/new.vue @@ -1,30 +1,32 @@ diff --git a/ui/scripts/build-icons.mjs b/ui/scripts/build-icons.mjs index 75401f28..641b5821 100644 --- a/ui/scripts/build-icons.mjs +++ b/ui/scripts/build-icons.mjs @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); -/** Every icon the shell, the Progress screen, the Login screen and the profile menu use. Keep sorted by set, then name. */ +/** Every icon the migrated screens, the shell and the profile menu use. Keep sorted by set, then name. */ const ICONS = [ 'logos:google-icon', 'solar:add-circle-bold', @@ -34,6 +34,8 @@ const ICONS = [ 'solar:calendar-minimalistic-linear', 'solar:card-2-bold-duotone', 'solar:chart-2-bold-duotone', + 'solar:chat-round-line-bold', + 'solar:chat-round-line-bold-duotone', 'solar:check-circle-bold', 'solar:check-circle-bold-duotone', 'solar:clock-circle-bold', @@ -48,13 +50,16 @@ const ICONS = [ 'solar:download-minimalistic-bold', 'solar:fire-bold', 'solar:fire-bold-duotone', + 'solar:global-linear', 'solar:hamburger-menu-linear', 'solar:history-2-bold-duotone', 'solar:inbox-in-bold-duotone', 'solar:layers-minimalistic-bold-duotone', + 'solar:lock-keyhole-bold', 'solar:lock-keyhole-minimalistic-bold-duotone', 'solar:logout-2-linear', 'solar:magnifer-linear', + 'solar:microphone-3-bold', 'solar:microphone-3-bold-duotone', 'solar:monitor-bold-duotone', 'solar:moon-bold-duotone', @@ -67,9 +72,11 @@ const ICONS = [ 'solar:rocket-2-bold-duotone', 'solar:settings-bold-duotone', 'solar:settings-linear', + 'solar:shuffle-bold', 'solar:sun-2-bold-duotone', 'solar:tuning-2-bold', 'solar:user-linear', + 'solar:user-speak-rounded-bold-duotone', ]; const setCache = new Map(); diff --git a/ui/src/elements/StProgressBar.vue b/ui/src/elements/StProgressBar.vue new file mode 100644 index 00000000..e96c24c3 --- /dev/null +++ b/ui/src/elements/StProgressBar.vue @@ -0,0 +1,57 @@ + + + diff --git a/ui/src/elements/StSegmentedControl.vue b/ui/src/elements/StSegmentedControl.vue new file mode 100644 index 00000000..4de7aacf --- /dev/null +++ b/ui/src/elements/StSegmentedControl.vue @@ -0,0 +1,74 @@ + + + diff --git a/ui/src/icon/icons.generated.ts b/ui/src/icon/icons.generated.ts index 5d88fcb6..c2b51739 100644 --- a/ui/src/icon/icons.generated.ts +++ b/ui/src/icon/icons.generated.ts @@ -21,6 +21,8 @@ export const icons: Record = { "solar:calendar-minimalistic-linear": { body: "", width: 24, height: 24 }, "solar:card-2-bold-duotone": { body: "", width: 24, height: 24 }, "solar:chart-2-bold-duotone": { body: "", width: 24, height: 24 }, + "solar:chat-round-line-bold": { body: "", width: 24, height: 24 }, + "solar:chat-round-line-bold-duotone": { body: "", width: 24, height: 24 }, "solar:check-circle-bold": { body: "", width: 24, height: 24 }, "solar:check-circle-bold-duotone": { body: "", width: 24, height: 24 }, "solar:clock-circle-bold": { body: "", width: 24, height: 24 }, @@ -35,13 +37,16 @@ export const icons: Record = { "solar:download-minimalistic-bold": { body: "", width: 24, height: 24 }, "solar:fire-bold": { body: "", width: 24, height: 24 }, "solar:fire-bold-duotone": { body: "", width: 24, height: 24 }, + "solar:global-linear": { body: "", width: 24, height: 24 }, "solar:hamburger-menu-linear": { body: "", width: 24, height: 24 }, "solar:history-2-bold-duotone": { body: "", width: 24, height: 24 }, "solar:inbox-in-bold-duotone": { body: "", width: 24, height: 24 }, "solar:layers-minimalistic-bold-duotone": { body: "", width: 24, height: 24 }, + "solar:lock-keyhole-bold": { body: "", width: 24, height: 24 }, "solar:lock-keyhole-minimalistic-bold-duotone": { body: "", width: 24, height: 24 }, "solar:logout-2-linear": { body: "", width: 24, height: 24 }, "solar:magnifer-linear": { body: "", width: 24, height: 24 }, + "solar:microphone-3-bold": { body: "", width: 24, height: 24 }, "solar:microphone-3-bold-duotone": { body: "", width: 24, height: 24 }, "solar:monitor-bold-duotone": { body: "", width: 24, height: 24 }, "solar:moon-bold-duotone": { body: "", width: 24, height: 24 }, @@ -54,9 +59,11 @@ export const icons: Record = { "solar:rocket-2-bold-duotone": { body: "", width: 24, height: 24 }, "solar:settings-bold-duotone": { body: "", width: 24, height: 24 }, "solar:settings-linear": { body: "", width: 24, height: 24 }, + "solar:shuffle-bold": { body: "", width: 24, height: 24 }, "solar:sun-2-bold-duotone": { body: "", width: 24, height: 24 }, "solar:tuning-2-bold": { body: "", width: 24, height: 24 }, "solar:user-linear": { body: "", width: 24, height: 24 }, + "solar:user-speak-rounded-bold-duotone": { body: "", width: 24, height: 24 }, }; export type IconName = keyof typeof icons; diff --git a/ui/src/index.ts b/ui/src/index.ts index a327cc21..c34cc876 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -20,6 +20,8 @@ export { default as StEmptyState } from './elements/StEmptyState.vue'; export { default as StIconButton } from './elements/StIconButton.vue'; export { default as StInput } from './elements/StInput.vue'; export { default as StModal } from './elements/StModal.vue'; +export { default as StProgressBar } from './elements/StProgressBar.vue'; +export { default as StSegmentedControl } from './elements/StSegmentedControl.vue'; export { default as StSkeleton } from './elements/StSkeleton.vue'; export { default as StSwitch } from './elements/StSwitch.vue'; export { default as StTextarea } from './elements/StTextarea.vue'; @@ -34,4 +36,5 @@ export { default as StAppShell } from './shell/StAppShell.vue'; export { default as StProfileMenu } from './shell/StProfileMenu.vue'; export { default as StSidebarNav } from './shell/StSidebarNav.vue'; +export type { StSegment } from './elements/StSegmentedControl.vue'; export type { StNavGroup, StNavItem, StProfileMenuItem, StTheme, StTone, StSize, StPadding, StElevation } from './types'; From a14b02ef7d9957baef13bed110588858d178b448 Mon Sep 17 00:00:00 2001 From: SomiVista Date: Wed, 2 Sep 2026 21:12:39 +0300 Subject: [PATCH 2/2] feat(ui): rebuild Start a session on subturtle-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds /sessions/new to the redesign: a two-column layout with the bundle picker and coach card on the left and a sticky session summary on the right. Session logic is unchanged — the same dataProvider bundle controller, form shape and defaults, selectionError/randomError validation, freemium gate and pickPhraseIds + encodeSessionRequest hand-off to /practice/live-session. Library: - StSegmentedControl and StProgressBar, ported from the design system's SegmentedControl.jsx / ProgressBar.jsx, plus seven Solar icons. Screen: - Bundle picker with search, Recent / Due today / Never practised / All chips, and a per-bundle "N phrases · N due" line on a colour-coded tile. - Coach cards carry the server voice list's descriptions and a Preview pill that plays a sample through the existing textToSpeechBase64 function. - Summary panel: bundle, mode, voice, fallback, phrase range, the voice-minute meter and the primary Start session button. - "Repeat your last session" replays the last setup from localStorage rather than from list-live-sessions, which is Learner+ and throws for the free tier. The picker's due counts need review state the client cannot read (leitner_system is owner-access), so get-phrase-management-info returns an additional duePhraseIds array. The field is additive; its only existing caller ignores it. Co-Authored-By: Claude Opus 5 --- frontend/components/bundle/BundleGlyph.vue | 39 ++ .../bundle/StartLiveSessionForm.vue | 151 +++++--- frontend/components/live/VoicePicker.vue | 90 +++-- .../liveSession/gemini/StartNew.vue | 358 ++++++++++++------ frontend/composables/useLastSession.ts | 39 ++ frontend/composables/useVoicePreview.ts | 76 ++++ frontend/locales/en.json | 55 ++- frontend/pages/sessions/new.vue | 30 +- .../unit/utils/bundleReviewState.test.ts | 54 +++ frontend/utils/bundleReviewState.ts | 32 ++ server/src/modules/leitner_box/service.ts | 9 + 11 files changed, 711 insertions(+), 222 deletions(-) create mode 100644 frontend/components/bundle/BundleGlyph.vue create mode 100644 frontend/composables/useLastSession.ts create mode 100644 frontend/composables/useVoicePreview.ts create mode 100644 frontend/tests/unit/utils/bundleReviewState.test.ts create mode 100644 frontend/utils/bundleReviewState.ts diff --git a/frontend/components/bundle/BundleGlyph.vue b/frontend/components/bundle/BundleGlyph.vue new file mode 100644 index 00000000..54bef00b --- /dev/null +++ b/frontend/components/bundle/BundleGlyph.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/components/bundle/StartLiveSessionForm.vue b/frontend/components/bundle/StartLiveSessionForm.vue index 373ba8b6..4be06384 100644 --- a/frontend/components/bundle/StartLiveSessionForm.vue +++ b/frontend/components/bundle/StartLiveSessionForm.vue @@ -1,84 +1,102 @@ + + diff --git a/frontend/components/liveSession/gemini/StartNew.vue b/frontend/components/liveSession/gemini/StartNew.vue index 324d0ca7..fa6c9808 100644 --- a/frontend/components/liveSession/gemini/StartNew.vue +++ b/frontend/components/liveSession/gemini/StartNew.vue @@ -1,11 +1,11 @@ @@ -171,16 +170,20 @@ import type { LivePracticeSessionSetupType } from '~/types/live-session.type'; import type { LiveSessionRequest } from '~/types/live-session-request'; import { pickPhraseIds, encodeSessionRequest } from '~/utils/livePractice'; - import { dataProvider } from '@modular-rest/client'; + import { bundlePhraseIds, dueCountFor, isNeverPractised } from '~/utils/bundleReviewState'; + import { dataProvider, functionProvider } from '@modular-rest/client'; import { COLLECTIONS, DATABASE, type PhraseBundleType } from '~/types/database.type'; import StartLiveSessionForm from '~/components/bundle/StartLiveSessionForm.vue'; + import BundleGlyph from '~/components/bundle/BundleGlyph.vue'; import FreemiumLimitationModal from '~/components/freemium_alerts/LimitationModal.vue'; - import VoiceMeter from '~/components/VoiceMeter.vue'; - import TextChatCounter from '~/components/TextChatCounter.vue'; import { useProfileStore } from '~/stores/profile'; import { useVoiceBalance } from '~/composables/useVoiceBalance'; + import { useLiveSessionVoices } from '~/composables/useLiveSessionVoices'; import { openVoiceCapModal } from '~/composables/useVoiceCapModal'; + import { rememberLastSession } from '~/composables/useLastSession'; + /** Offline fallback. The server list is the same eight voices plus the one-line + * descriptions the coach cards show, so it wins whenever it has loaded. */ const GEMINI_VOICES = ['Kore', 'Puck', 'Charon', 'Fenrir', 'Aoede', 'Leda', 'Orus', 'Zephyr']; /** How many of the newest bundles the "Recent" chip keeps. */ @@ -189,12 +192,20 @@ const router = useRouter(); const { t } = useI18n(); const profileStore = useProfileStore(); - const { remaining: voiceRemaining, leftLabel: voiceLeftLabel } = useVoiceBalance(); + const { voices: serverVoices, ensureLoaded: ensureVoicesLoaded } = useLiveSessionVoices(); + const voiceOptions = computed(() => (serverVoices.value.length ? serverVoices.value : GEMINI_VOICES)); + + const { base: voiceBase, baseRemaining: voiceLeft, used: voiceUsed, remaining: voiceRemaining, renewalDate } = useVoiceBalance(); const bundleList = ref([]); const filter = ref(''); const isLoadingBundles = ref(true); - const scope = ref<'recent' | 'all'>('recent'); + const scope = ref<'recent' | 'due' | 'new' | 'all'>('recent'); + + // Leitner state for the picker's "N due" counts and its Due today / Never practised + // filters. `leitner_system` is owner-access, so this comes from the RPC. + const duePhraseIds = ref>(new Set()); + const practisedPhraseIds = ref>(new Set()); const controller = dataProvider.list( { @@ -216,6 +227,8 @@ ); onMounted(async () => { + ensureVoicesLoaded(); + try { await controller.updatePagination(); await controller.fetchPage(1); @@ -224,6 +237,18 @@ } finally { isLoadingBundles.value = false; } + + // Best-effort: the picker still works without it, just without due counts. + try { + const info = await functionProvider.run<{ duePhraseIds?: string[]; phraseToBoxMap?: Record }>({ + name: 'get-phrase-management-info', + args: { userId: authUser.value?.id }, + }); + duePhraseIds.value = new Set(info?.duePhraseIds ?? []); + practisedPhraseIds.value = new Set(Object.keys(info?.phraseToBoxMap ?? {})); + } catch (error) { + console.error('Failed to load review state for bundle counts:', error); + } }); const formRef = ref | null>(null); @@ -244,9 +269,20 @@ return formData.selectionMode === 'selection' ? !formRef.value.selectionError : !formRef.value.randomError; }); + const dueCountOf = (bundle: any) => dueCountFor(bundle, duePhraseIds.value); + const neverPractised = (bundle: any) => isNeverPractised(bundle, practisedPhraseIds.value); + + function bundleMeta(bundle: any) { + const phrases = t('live-session.pick-bundle.phrase-count', bundle.phrases?.length ?? 0); + const due = dueCountOf(bundle); + return due > 0 ? `${phrases} · ${t('live-session.pick-bundle.due-count', { n: due })}` : phrases; + } + const scopeChips = computed(() => [ { value: 'recent' as const, label: t('live-session.pick-bundle.scope-recent') }, - { value: 'all' as const, label: t('live-session.pick-bundle.scope-all') }, + { value: 'due' as const, label: t('live-session.pick-bundle.scope-due') }, + { value: 'new' as const, label: t('live-session.pick-bundle.scope-new') }, + { value: 'all' as const, label: t('live-session.pick-bundle.scope-all', { count: bundleList.value.length }) }, ]); // The controller's query captures `filter` once at setup (it is built a single time), so @@ -256,17 +292,60 @@ const needle = filter.value.trim().toLowerCase(); return needle ? bundleList.value.filter((b) => (b.title || '').toLowerCase().includes(needle)) : bundleList.value; }); - const visibleBundles = computed(() => (scope.value === 'recent' ? filteredBundles.value.slice(0, RECENT_COUNT) : filteredBundles.value)); + + const visibleBundles = computed(() => { + const list = filteredBundles.value; + if (scope.value === 'recent') return list.slice(0, RECENT_COUNT); + if (scope.value === 'due') return list.filter((b) => dueCountOf(b) > 0); + if (scope.value === 'new') return list.filter(neverPractised); + return list; + }); const selectedBundle = computed(() => bundleList.value.find((b) => b._id === formData.bundleId) || null); + // "42 phrases · ES → EN". The bundle list carries phrase ids only, so the language pair + // comes from one phrase of the chosen bundle; it is dropped when that phrase does not + // record one (older `normal` phrases often do not). + const languagePair = ref(''); + watch( + () => formData.bundleId, + async (id) => { + languagePair.value = ''; + const ids = bundlePhraseIds(selectedBundle.value); + if (!id || !ids.length) return; + try { + const phrase = await dataProvider.findOne({ + database: DATABASE.USER_CONTENT, + collection: COLLECTIONS.PHRASE, + query: { _id: ids[0] }, + }); + const info = phrase?.language_info; + if (info?.source && info?.target) languagePair.value = `${short(info.source)} → ${short(info.target)}`; + } catch (error) { + console.error('Failed to resolve bundle language pair:', error); + } + } + ); + + /** "Spanish" → "ES"; an already-short code is passed through. */ + function short(language: string) { + const trimmed = String(language || '').trim(); + if (!trimmed) return ''; + if (trimmed.length <= 3) return trimmed.toUpperCase(); + return trimmed.slice(0, 2).toUpperCase(); + } + + const summaryBundleMeta = computed(() => { + const phrases = t('live-session.pick-bundle.phrase-count', selectedBundle.value?.phrases?.length ?? 0); + return languagePair.value ? `${phrases} · ${languagePair.value}` : phrases; + }); + const summaryRows = computed(() => [ - { label: t('live-session.summary.bundle'), value: selectedBundle.value?.title || '—' }, - { label: t('live-practice.mode.label'), value: formData.mode === 'text' ? t('live-practice.mode.text') : t('live-practice.mode.voice') }, - ...(formData.mode === 'text' ? [] : [{ label: t('live-practice.ai-character'), value: formData.aiCharacter }]), + { label: t('live-session.summary.mode'), value: formData.mode === 'text' ? t('live-practice.mode.text') : t('live-practice.mode.voice') }, + ...(formData.mode === 'text' ? [] : [{ label: t('live-session.summary.coach-voice'), value: formData.aiCharacter }]), { label: t('live-session.summary.fallback'), - value: formData.nativeLanguage === 'auto' ? t('live-practice.native-language-auto') : formData.nativeLanguage, + value: formData.nativeLanguage === 'auto' ? t('live-session.summary.fallback-auto') : formData.nativeLanguage, }, { label: t('live-session.summary.phrases'), @@ -279,23 +358,64 @@ const canStart = computed(() => isFormValid.value && !!formData.bundleId); - // The freemium allowance behind the summary meter: sessions in voice mode, text chats in - // text mode — the same split FreemiumLimitCard made, in the panel the design asks for. - const freemium = computed(() => { + /** + * The meter under the summary. A text session spends no voice minutes, so in text mode it + * shows the allowance that mode actually consumes: the freemium text-chat count, or (for a + * paid tier with unlimited text) nothing worth a bar. + */ + const budget = computed(() => { const allocation = profileStore.freemiumAllocation as any; - const isText = formData.mode === 'text'; - const used = (isText ? allocation?.allowed_text_chats_used : allocation?.allowed_lived_sessions_used) ?? 0; - const total = (isText ? allocation?.allowed_text_chats : allocation?.allowed_lived_sessions) ?? 0; - const pct = total > 0 ? (used / total) * 100 : 0; + const sub = profileStore.activeSubscription as any; + + // Text mode spends no voice minutes, so it shows the allowance it does spend. + if (formData.mode === 'text') { + const used = (profileStore.isFreemium ? allocation?.allowed_text_chats_used : sub?.allowed_text_chats_used) ?? 0; + const total = (profileStore.isFreemium ? allocation?.allowed_text_chats : sub?.allowed_text_chats) ?? 0; + const pct = total > 0 ? (used / total) * 100 : 0; + return { + label: t('live-session.summary.text-chats'), + value: total > 0 ? t('live-session.summary.n-of-m-left', { left: Math.max(0, total - used), total }) : t('live-session.summary.unlimited'), + used, + total, + atLimit: profileStore.isFreemium && total > 0 && used >= total, + note: resetNote.value, + color: pct >= 100 ? ('danger' as const) : pct >= 80 ? ('warning' as const) : ('primary' as const), + }; + } + + // Voice minutes, for every tier — the freemium allocation carries them too. + const pct = voiceBase.value > 0 ? (voiceUsed.value / voiceBase.value) * 100 : 0; + const sessionsUsed = allocation?.allowed_lived_sessions_used ?? 0; + const sessionsTotal = allocation?.allowed_lived_sessions ?? 0; return { - label: isText ? t('live-session.summary.free-chats') : t('live-session.summary.free-sessions'), - used, - total, - atLimit: total > 0 && used >= total, + label: t('live-session.summary.voice-minutes'), + value: t('live-session.summary.n-of-m-left', { left: voiceLeft.value, total: voiceBase.value }), + used: voiceUsed.value, + total: voiceBase.value, + // On the free tier it is the session count, not the minutes, that blocks a start. + atLimit: profileStore.isFreemium && sessionsTotal > 0 && sessionsUsed >= sessionsTotal, + note: [resetNote.value, t('live-session.summary.text-no-minutes')].filter(Boolean).join(' '), color: pct >= 100 ? ('danger' as const) : pct >= 80 ? ('warning' as const) : ('primary' as const), }; }); + const resetNote = computed(() => { + const raw = renewalDate.value; + if (!raw) return ''; + try { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return ''; + return t('live-session.summary.resets-on', { date: date.toLocaleDateString(undefined, { day: 'numeric', month: 'long' }) }); + } catch { + return ''; + } + }); + + function resetFilters() { + filter.value = ''; + scope.value = 'all'; + } + function goToBundles() { router.push('/bundles'); } @@ -340,6 +460,11 @@ source: { phraseIds: pickPhraseIds(bundle?.phrases ?? [], sessionData) }, returnTo: '/sessions/new', }; + + // Feeds the header's "Repeat your last session". Stored here rather than read back + // from session history, which is a Learner+ RPC that throws for the free tier. + rememberLastSession({ bundleId: formData.bundleId, mode: formData.mode, setup: sessionData, title: bundle?.title }); + const session = encodeSessionRequest(request); router.push(`/practice/live-session?session=${encodeURIComponent(session)}`); } @@ -347,4 +472,23 @@ function handleConfirmUpgrade() { router.push('/settings/subscription'); } + + /** Re-applies the last session's setup to the form; the page's own Start does the rest. */ + function applyLastSession(last: { bundleId: string; mode: 'voice' | 'text'; setup: LivePracticeSessionSetupType }) { + if (!bundleList.value.some((b) => b._id === last.bundleId)) return false; + formData.bundleId = last.bundleId; + formData.mode = last.mode; + formData.aiCharacter = last.setup.aiCharacter; + formData.nativeLanguage = last.setup.nativeLanguage ?? 'auto'; + formData.selectionMode = last.setup.selectionMode; + if (last.setup.selectionMode === 'random') { + formData.totalPhrases = String(last.setup.totalPhrases ?? 10); + } else { + formData.fromPhrase = String(last.setup.fromPhrase ?? 1); + formData.toPhrase = String(last.setup.toPhrase ?? 10); + } + return true; + } + + defineExpose({ applyLastSession }); diff --git a/frontend/composables/useLastSession.ts b/frontend/composables/useLastSession.ts new file mode 100644 index 00000000..c5598755 --- /dev/null +++ b/frontend/composables/useLastSession.ts @@ -0,0 +1,39 @@ +import type { LivePracticeSessionSetupType } from '~/types/live-session.type'; + +/** + * The setup of the most recent session this browser started, so "Repeat your last session" + * can replay it in one click. + * + * Kept in localStorage rather than read back from history: `list-live-sessions` is a + * Learner+ entitlement that throws for the free tier, so sourcing the button from it would + * make it fail for exactly the users most likely to press it. The trade-off is that it is + * per-browser and does not follow the account. + */ +const KEY = 'subturtle:last-live-session'; + +export interface LastSession { + bundleId: string; + mode: 'voice' | 'text'; + setup: LivePracticeSessionSetupType; + title?: string; + at: number; +} + +export function rememberLastSession(entry: Omit) { + try { + localStorage.setItem(KEY, JSON.stringify({ ...entry, at: Date.now() })); + } catch { + // Private mode / blocked storage: the button just stays hidden. + } +} + +export function readLastSession(): LastSession | null { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as LastSession; + return parsed?.bundleId && parsed?.setup ? parsed : null; + } catch { + return null; + } +} diff --git a/frontend/composables/useVoicePreview.ts b/frontend/composables/useVoicePreview.ts new file mode 100644 index 00000000..7f4959c2 --- /dev/null +++ b/frontend/composables/useVoicePreview.ts @@ -0,0 +1,76 @@ +import { ref } from 'vue'; +import { functionProvider } from '@modular-rest/client'; + +/** + * Short spoken sample for a coach voice, behind the picker's "Preview" pill. + * + * The Gemini Live prebuilt voices (Kore, Puck, …) are not addressable outside a live + * session — there is no endpoint that renders one to a file — so a sample is synthesised + * through the existing `textToSpeechBase64` function with the closest Google voice per + * coach. It conveys the tone the description promises; it is NOT the exact voice the + * session will use. Swap MAP for real per-coach clips as soon as any exist. + */ +const GOOGLE_VOICE_BY_COACH: Record = { + Kore: 'en-US-Neural2-C', + Puck: 'en-US-Neural2-D', + Charon: 'en-US-Neural2-A', + Fenrir: 'en-US-Neural2-I', + Aoede: 'en-US-Neural2-F', + Leda: 'en-US-Neural2-G', + Orus: 'en-US-Neural2-J', + Zephyr: 'en-US-Neural2-H', +}; + +const SAMPLE_TEXT = "Hi, I'm your coach. Ready when you are."; + +// Module-level so only one sample can play at a time, and a second click on the +// playing card stops it rather than layering a second voice on top. +const playing = ref(null); +const loading = ref(null); +let audio: HTMLAudioElement | null = null; +const cache = new Map(); + +export function useVoicePreview() { + function stop() { + audio?.pause(); + audio = null; + playing.value = null; + } + + async function preview(coach: string) { + if (playing.value === coach) return stop(); + stop(); + + const voiceName = GOOGLE_VOICE_BY_COACH[coach]; + if (!voiceName) return; + + try { + let src = cache.get(coach); + if (!src) { + loading.value = coach; + const res = await functionProvider.run({ + name: 'textToSpeechBase64', + args: { text: SAMPLE_TEXT, languageCode: 'en-US', voiceName }, + }); + const base64 = typeof res === 'string' ? res : res?.audioContent || res?.audioContentBase64; + if (!base64) return; + src = `data:audio/mp3;base64,${base64}`; + cache.set(coach, src); + } + + audio = new Audio(src); + playing.value = coach; + audio.onended = () => { + if (playing.value === coach) playing.value = null; + }; + await audio.play(); + } catch (e) { + console.error('voice preview failed', e); + playing.value = null; + } finally { + loading.value = null; + } + } + + return { preview, stop, playing, loading }; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c74455ce..eb9aeb31 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -135,7 +135,7 @@ "ai-character": "AI Character", "native-language": "Your native language", "native-language-hint": "The coach will explain instructions and feedback in this language; English is used for the practice itself.", - "native-language-auto": "Auto (detect from my speech)", + "native-language-auto": "Auto — detect what I speak", "mode": { "label": "Practice mode", "voice": "Voice", @@ -201,14 +201,25 @@ "hands-free-label": "Hands-free" }, "coach": { - "title": "Your coach", - "subtitle-voice": "Pick the voice you want to hear.", + "title": "Who's coaching you?", + "subtitle-voice": "Tap to hear a few seconds.", "subtitle-text": "A text session is written only — no voice to pick." }, "phrases": { "title": "Phrases this session" }, - "pick-randomly": "Pick randomly instead" + "pick-randomly": "Pick randomly", + "voice": { + "preview": "Preview", + "playing": "Playing", + "preview-aria": "Hear a sample of {name}", + "stop": "Stop the {name} sample" + }, + "fallback-label": "Falls back to", + "pick-range": "Pick a range", + "at-random": "at random", + "to": "to", + "about-minutes": "about {n} min" }, "subscription": { "title": "Subscription", @@ -486,7 +497,7 @@ "live-session": { "no-sessions": "No Sessions Yet", "no-sessions-description": "You haven't started any practice sessions yet. Start a live session from a phrase bundle to practice your language skills and come back here to see your session history.", - "start-session": "Start Session", + "start-session": "Start session", "start-first-session": "Start Your First Session", "6-months-expiry": "Each session will expire in 6 months", "session-history": "Session history", @@ -502,29 +513,45 @@ "total-cost": "Total Cost (USD)", "duration": "Duration", "start-new-session": "Start New Session", - "overline": "LIVE SESSIONS", - "start-new-subtitle": "Pick a bundle, choose a coach, and talk your phrases into memory.", + "overline": "AI COACHING", + "start-new-subtitle": "Pick a bundle, choose how you want to talk, and Coach takes it from there.", "no-bundles": { "title": "No bundles to practise yet", "description": "A live session runs on a phrase bundle. Save a few phrases from YouTube, Netflix or any page and one will be waiting here." }, "pick-bundle": { - "title": "Pick a bundle", - "subtitle": "The coach will practise the phrases inside it with you.", + "title": "Which bundle?", + "subtitle": "Coach only uses phrases from the set you pick.", + "search": "Search {count} bundles", "phrase-count": "{count} phrase | {count} phrases", + "due-count": "{n} due", "scope-recent": "Recent", - "scope-all": "All" + "scope-due": "Due today", + "scope-new": "Never practised", + "scope-all": "All {count}", + "no-matches": "No bundles here", + "no-matches-description": "Nothing matches that search and filter. Try another word, or switch to All." }, "summary": { "title": "Your session", - "bundle": "Bundle", - "fallback": "Falls back to", + "mode": "Mode", + "coach-voice": "Coach voice", + "fallback": "Fallback", + "fallback-auto": "Auto", "phrases": "Phrases", "random-count": "{n} at random", + "voice-minutes": "Voice minutes", "free-sessions": "Free sessions", "free-chats": "Free text chats", - "pick-first": "Pick a bundle to start." - } + "n-of-m-left": "{left} of {total} left", + "resets-on": "Resets on {date}.", + "text-no-minutes": "Text chat doesn't use minutes.", + "pick-first": "Pick a bundle to get started.", + "text-chats": "Text chats", + "unlimited": "Unlimited" + }, + "start-a-session": "Start a session", + "repeat-last": "Repeat your last session" }, "profile": { "profile": "Profile", diff --git a/frontend/pages/sessions/new.vue b/frontend/pages/sessions/new.vue index 0086bc20..6119cae5 100644 --- a/frontend/pages/sessions/new.vue +++ b/frontend/pages/sessions/new.vue @@ -1,32 +1,42 @@