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
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ jobs:
- name: Install Playwright system deps only
if: steps.pw-cache.outputs.cache-hit == 'true'
run: yarn playwright install-deps chromium
- name: Create .env for dev server
- name: Create .env for build
run: echo "NUXT_PUBLIC_BASE_URL_API=http://localhost:8080" > .env
- run: yarn build
- run: yarn test:e2e
env:
CI: 'true'
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Modules are discovered dynamically by the modular-rest framework. Entry point: [
- **Mongo spans multiple databases** (`user_content`, `subturtle_leitner`, `subturtle_board`, `cms`). Always check [server/src/config.ts](server/src/config.ts) before adding a collection.
- **Live-session audio formats are fixed**: mic input is 16 kHz Int16 PCM via an AudioWorklet (`pcm16-downsampler`); server audio comes back at 24 kHz and is queued as gapless `AudioBufferSourceNode`s. Don't change rates without updating the worklet.
- **Yarn only** β€” both workspaces ship `yarn.lock`. Mixing `npm install` will desync the lockfile.
- **pilotui `<Button :to="url">` renders a disabled-looking link** β€” in link mode it emits `<a disabled="false">`, and the `.btn[disabled]` rule fades it (`opacity: 0.6`, `cursor: not-allowed`). For button-styled links, use `@click` with programmatic navigation instead of `:to`.

## Testing

Expand Down
38 changes: 17 additions & 21 deletions frontend/components/bundle/StartLiveSessionFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,24 @@
<StartLiveSessionForm v-model="formData" ref="formRef" />

<template #footer>
<div class="-m-5">
<!-- Freemium: Show freemium limit card -->
<div v-if="profileStore.isFreemium">
<FreemiumLimitationModal :modal-title="t('freemium.limitation.title')"
:main-message="t('freemium.limitation.no_free_spots_left')"
:sub-message="t('freemium.limitation.upgrade_to_pro_message')"
:primary-button-label="t('freemium.limitation.go_pro')"
:secondary-button-label="t('freemium.limitation.continue_with_limits')"
@upgrade="handleConfirmUpgrade">
<template #trigger="{ toggleModal: toggleLimitationModal }">
<FreemiumLimitCard type="liveSession" :action-label="t('live-practice.start')"
@action="startSession" @upgrade="toggleLimitationModal(true)" />
</template>
</FreemiumLimitationModal>
</div>

<!-- Premium: Regular start button -->
<div v-else>
<Button color="primary" :disabled="!isFormValid" @click="startSession"
:label="t('live-practice.start')" />
</div>
<!-- Freemium: full-bleed limit card β€” the -m-5 cancels the modal footer's p-5 so the gradient card reaches the edges -->
<div v-if="profileStore.isFreemium" class="-m-5">
<FreemiumLimitationModal :modal-title="t('freemium.limitation.title')"
:main-message="t('freemium.limitation.no_free_spots_left')"
:sub-message="t('freemium.limitation.upgrade_to_pro_message')"
:primary-button-label="t('freemium.limitation.go_pro')"
:secondary-button-label="t('freemium.limitation.continue_with_limits')"
@upgrade="handleConfirmUpgrade">
<template #trigger="{ toggleModal: toggleLimitationModal }">
<FreemiumLimitCard type="liveSession" :action-label="t('live-practice.start')"
@action="startSession" @upgrade="toggleLimitationModal(true)" />
</template>
</FreemiumLimitationModal>
</div>

<!-- Premium: Regular start button -->
<Button v-else color="primary" block :disabled="!isFormValid" @click="startSession"
:label="t('live-practice.start')" />
</template>
</Modal>
</template>
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/freemium_alerts/LimitationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ const props = withDefaults(
// Default values - plain strings, translations handled by parent
modalTitle: 'Upgrade Required',
mainMessage: 'No free spots left',
subMessage: 'Upgrade to Pro to access unlimited features.',
subMessage: 'Upgrade to Learner to access unlimited features.',
iconName: 'IconLock',
primaryButtonLabel: 'Go Pro!',
primaryButtonLabel: 'Upgrade to Learner',
primaryButtonIcon: 'IconCrown',
secondaryButtonLabel: 'Continue with limits',
showSecondaryButton: true,
Expand Down
42 changes: 42 additions & 0 deletions frontend/components/freemium_alerts/UsageCapBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div v-if="state !== 'hidden'" class="flex items-center gap-3 px-4 py-2.5 text-sm" :class="state === 'paused'
? 'bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-200'
: 'bg-amber-50 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200'">
<span class="flex-1">
{{ state === 'paused' ? t('subscription.cap-banner.paused') : t('subscription.cap-banner.running-low') }}
</span>
<Button size="sm" color="primary" :label="t('subscription.cap-banner.upgrade-cta')" @click="goToPlans" />
<button type="button" class="flex-shrink-0 px-1 text-lg leading-none opacity-60 hover:opacity-100"
:aria-label="t('subscription.cap-banner.dismiss')" @click="dismissed = true">
Γ—
</button>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { Button } from 'pilotui/elements';
import { useProfileStore } from '~/stores/profile';

// Usage % at which the "running low" warning appears. Hard cap (AI paused) is
// always 100%. Mirrors SOFT_CAP_PERCENT in the subscription module config.
const SOFT_CAP_PERCENT = 80;

const profileStore = useProfileStore();
const router = useRouter();
const { t } = useI18n();

// Dismissal is per session β€” we don't nag once the user has acknowledged it.
const dismissed = ref(false);

const state = computed<'hidden' | 'warning' | 'paused'>(() => {
if (dismissed.value) return 'hidden';
if (profileStore.isAiPaused) return 'paused';
if (profileStore.usagePercentage >= SOFT_CAP_PERCENT) return 'warning';
return 'hidden';
});

function goToPlans() {
router.push('/settings/subscription');
}
</script>
4 changes: 2 additions & 2 deletions frontend/components/liveSession/gemini/StartNew.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
</option>
</select>
</Card>
<Card class="space-y-4 !p-0 shadow-none" :class="{ 'cursor-not-allowed opacity-50': !formData.bundleId }">
<Card class="!p-0 shadow-none" :class="{ 'cursor-not-allowed opacity-50': !formData.bundleId }">
<StartLiveSessionForm class="m-4" v-model="formData" :voice-options="GEMINI_VOICES" ref="formRef"
@start="handleStartLiveSession" />

<!-- Freemium: Show freemium limit card -->
<div v-if="profileStore.isFreemium">
<div class="m-4" v-if="profileStore.isFreemium">
<FreemiumLimitationModal :modal-title="t('freemium.limitation.title')"
:main-message="t('freemium.limitation.no_free_spots_left')"
:sub-message="t('freemium.limitation.upgrade_to_pro_message')"
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/liveSession/openai/StartNew.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
</option>
</select>
</Card>
<Card class="space-y-4 !p-0 shadow-none" :class="{ 'cursor-not-allowed opacity-50': !formData.bundleId }">
<Card class="!p-0 shadow-none" :class="{ 'cursor-not-allowed opacity-50': !formData.bundleId }">
<StartLiveSessionForm class="m-4" v-model="formData" :voice-options="OPENAI_VOICES" ref="formRef"
@start="handleStartLiveSession" />

<!-- Freemium: Show freemium limit card -->
<div v-if="profileStore.isFreemium">
<div class="m-4" v-if="profileStore.isFreemium">
<FreemiumLimitationModal :modal-title="t('freemium.limitation.title')"
:main-message="t('freemium.limitation.no_free_spots_left')"
:sub-message="t('freemium.limitation.upgrade_to_pro_message')"
Expand Down
11 changes: 0 additions & 11 deletions frontend/components/profile/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,6 @@
</div>
</button>
</NuxtLink>

<NuxtLink to="/settings/billing">
<button type="button"
class="flex h-10 w-full items-center justify-between rounded-md p-2 font-medium hover:bg-white-dark/10 hover:text-primary dark:hover:bg-[#181F32] dark:hover:text-primary"
:class="{ 'bg-gray-100 text-primary dark:bg-[#181F32] dark:text-primary': activeTab === 'billing' }">
<div class="flex items-center">
<Icon name="IconClipboardText" class="shrink-0" />
<div class="ltr:ml-3 rtl:mr-3">{{ t('billing.billing') }}</div>
</div>
</button>
</NuxtLink>
</div>
</div>
</client-only>
Expand Down
5 changes: 0 additions & 5 deletions frontend/composables/useDashboardNavigatorItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,6 @@ export const useDashboardNavigatorItems = (): Array<SidebarGroupType> => {
icon: 'IconCreditCard',
to: '/settings/subscription',
},
// {
// title: t('billing.billing'),
// icon: 'IconClipboardText',
// to: '/settings/billing',
// },
],
},
] as Array<SidebarGroupType>;
Expand Down
12 changes: 12 additions & 0 deletions frontend/constants/analyticsEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Shared registry of Mixpanel event names β€” keeps event names consistent and
// greppable across the app. Fired via `analytic.track(...)` (see ~/plugins/mixpanel).
// The server fires its own copies of the server-truth events via
// server/src/utils/analytics.ts.
export const ANALYTICS_EVENTS = {
CAP_HIT: 'cap-hit', // props: { cap: 'save_words' | 'ai_taste' }
TRIAL_STARTED: 'trial-started', // props: { cadence, currency }
TRIAL_CONVERTED: 'trial-converted', // server-fired
TRIAL_CANCELED: 'trial-canceled', // server-fired
STARTER_AI_EXHAUSTED: 'starter-ai-exhausted', // server-fired
FLUENT_WAITLIST_SIGNUP: 'fluent-waitlist-signup',
} as const;
2 changes: 2 additions & 0 deletions frontend/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</template>

<template #content>
<UsageCapBanner />
<NuxtPage />
</template>
</DashboardShell>
Expand All @@ -33,6 +34,7 @@
<script setup lang="ts">
import { App, DashboardShell, ThemeCustomizer, SidebarMenu, HorizontalMenu } from 'pilotui/shell';
import type { SidebarItemType, HorizontalMenuItemType } from 'pilotui/types';
import UsageCapBanner from '~/components/freemium_alerts/UsageCapBanner.vue';

const menuItems = useDashboardNavigatorItems();
const router = useRouter();
Expand Down
60 changes: 51 additions & 9 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,21 @@
"limitation": {
"title": "Upgrade Required",
"no_phrase_spots_left": "No free phrase spots left",
"upgrade_message": "Upgrade to Pro to save and practice phrases without limits.",
"upgrade_message": "Upgrade to Learner to save and practice phrases without limits.",
"free_spots_left": "Free spots left to save phrases",
"free_sessions_left": "Free live practice sessions left",
"go_pro": "Go Pro!",
"go_pro": "Upgrade to Learner",
"upgrade_now": "Upgrade Now",
"upgrade": "Upgrade",
"continue_with_limits": "Continue with limits :(",
"no_free_spots_left": "No free phrase spots left.",
"upgrade_to_pro_message": "Upgrade to Pro to save and practice phrases without limits."
"upgrade_to_pro_message": "Upgrade to Learner to save and practice phrases without limits."
},
"timer": {
"remaining_time": "Free session time remaining",
"time_expired": "Free Session Time Expired",
"session_limit_reached": "Your 5-minute free session limit has been reached.",
"upgrade_for_unlimited": "Upgrade to Pro for unlimited session time.",
"upgrade_for_unlimited": "Upgrade to Learner for unlimited session time.",
"exit_session": "Exit Session"
}
},
Expand Down Expand Up @@ -176,12 +176,9 @@
"verification-failed": "Payment verification failed",
"payment-result": "Payment Result",
"monthly-subscription": "Monthly Subscription",
"monthly-description": "Access to all features for 30 days with 1000 credits",
"monthly-description": "Access to all features for 30 days",
"features": "What's Included",
"active-plan": "Active Plan",
"freemium": "Freemium Plan",
"premium": "Premium Plan",
"pro": "Pro Plan",
"joined-at": "Joined at",
"month": "Month",
"current-plan": "Current Plan",
Expand All @@ -193,7 +190,52 @@
"available": "Available",
"used": "Used",
"credit-in-usd": "Credit in USD",
"plan": "Plan"
"plan": "Plan",
"trial-active": "Free trial β€” {days} days left",
"canceling": "Cancels on {date}",
"pricing": {
"annual-toggle": "Save 20% with annual billing",
"most-popular": "Most popular",
"coming-soon": "Coming soon",
"month": "month",
"year": "year",
"or-monthly": "or {price}/mo billed monthly",
"starter-price": "Free, forever. No card needed.",
"free-plan": "Free plan",
"current-plan": "Current plan",
"learner-cta": "Start 3-day free trial",
"learner-subline": "Try free for 3 days. Cancel any time.",
"fluent-cta": "Notify me when it's ready",
"fluent-notified": "You're on the list βœ“",
"fluent-helper": "We'll email you as soon as Fluent is available."
},
"cap-banner": {
"running-low": "You're running low on AI tools this month.",
"paused": "AI features are paused for this month β€” your saves and Smart Review still work.",
"upgrade-cta": "Upgrade",
"dismiss": "Dismiss"
},
"ai-cap": {
"title": "AI tools paused",
"message": "That was your last AI session for this month.",
"sub-message": "Free users get a taste of Subturtle's AI tools β€” go Learner for the full monthly budget.",
"primary": "Go Learner",
"secondary": "Show me free tools"
},
"save-cap": {
"title": "Nice run!",
"message": "You've saved 200 phrases β€” nice run.",
"sub-message": "Free users get 200 saves per month. To keep saving without limits, go Learner.",
"primary": "See plans",
"secondary": "Maybe later"
},
"cancel-offramp": {
"title": "Before you go",
"message": "Stay on Free instead?",
"sub-message": "You keep 200 saves a month and unlimited Smart Review β€” no card needed.",
"stay": "Stay on Free",
"continue": "Continue to cancel"
}
},
"auth": {
"signin_with_google": "SIGN IN WITH GOOGLE",
Expand Down
37 changes: 30 additions & 7 deletions frontend/pages/bundles/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
<div class="flex w-full flex-col items-end justify-end md:w-auto md:flex-row">
<!-- Freemium: Combined Limitation + Add Phrase in Beautiful Gradient Wrapper -->
<div v-if="profileStore.isFreemium" class="w-full md:w-auto">
<FreemiumLimitationModal :modal-title="t('freemium.limitation.title')"
:main-message="t('freemium.limitation.no_free_spots_left')"
:sub-message="t('freemium.limitation.upgrade_to_pro_message')"
:primary-button-label="t('freemium.limitation.go_pro')"
:secondary-button-label="t('freemium.limitation.continue_with_limits')"
auto-redirect-on-upgrade>
<FreemiumLimitationModal :modal-title="t('subscription.save-cap.title')"
:main-message="t('subscription.save-cap.message')"
:sub-message="t('subscription.save-cap.sub-message')"
:primary-button-label="t('subscription.save-cap.primary')"
:secondary-button-label="t('subscription.save-cap.secondary')"
auto-redirect-on-upgrade @secondary="snoozeSaveCap">
<template #trigger="{ toggleModal }">
<FreemiumLimitCard type="phrase" @action="handleFreemiumAddPhrase"
@upgrade-needed="toggleModal(true)" />
@upgrade-needed="onSaveCapUpgradeNeeded(toggleModal)" />
</template>
</FreemiumLimitationModal>
</div>
Expand Down Expand Up @@ -172,6 +172,8 @@ import type { LivePracticeSessionSetupType } from '~/types/live-session.type';
import { useProfileStore } from '~/stores/profile';
import FreemiumLimitCard from '~/components/freemium_alerts/FreemiumLimitCard.vue';
import FreemiumLimitationModal from '~/components/freemium_alerts/LimitationModal.vue';
import { analytic } from '~/plugins/mixpanel';
import { ANALYTICS_EVENTS } from '~/constants/analyticsEvents';

const { t } = useI18n();
const router = useRouter();
Expand Down Expand Up @@ -238,6 +240,27 @@ function handleFreemiumAddPhrase() {
bundleStore.addEmptyTemporarilyPhrase();
}

// 200-save cap modal: "Maybe later" snoozes the modal for 24h; it never shows
// twice in the same session. Once snoozed/shown, a further click on the
// at-limit card skips the interstitial and goes straight to the plans page.
const SAVE_CAP_SNOOZE_KEY = 'subturtle_save_cap_snooze_until';
const saveCapShownThisSession = ref(false);

function onSaveCapUpgradeNeeded(toggleModal: (value: boolean) => void) {
const snoozedUntil = Number(localStorage.getItem(SAVE_CAP_SNOOZE_KEY) || 0);
if (saveCapShownThisSession.value || Date.now() < snoozedUntil) {
router.push('/settings/subscription');
return;
}
saveCapShownThisSession.value = true;
analytic.track(ANALYTICS_EVENTS.CAP_HIT, { cap: 'save_words' });
toggleModal(true);
}

function snoozeSaveCap() {
localStorage.setItem(SAVE_CAP_SNOOZE_KEY, String(Date.now() + 24 * 60 * 60 * 1000));
}

async function openReviewModal() {
isReviewModalOpen.value = true;
loadingAllPhrases.value = true;
Expand Down
Loading
Loading