diff --git a/openspec/changes/chat-quota-system/.openspec.yaml b/openspec/changes/chat-quota-system/.openspec.yaml new file mode 100644 index 0000000..2fe001e --- /dev/null +++ b/openspec/changes/chat-quota-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-07 diff --git a/openspec/changes/chat-quota-system/design.md b/openspec/changes/chat-quota-system/design.md new file mode 100644 index 0000000..6d8b65b --- /dev/null +++ b/openspec/changes/chat-quota-system/design.md @@ -0,0 +1,72 @@ +## Context + +The server has introduced a monthly quota system for AI chat (text and voice). The app currently has no awareness of user plans or usage limits. The bot service (`src/services/botservice.ts`) sends requests to `POST /chat/text` and opens WebSockets to `/chat/voice-bot-websocket` without handling quota-related error codes. The Settings screen has no plan/usage information. + +Current state: +- `botservice.ts` does not read rate-limit response headers +- No `planService` exists +- Text chat catches generic errors but not HTTP 429 specifically +- Voice WebSocket does not differentiate close code 4029 from other failures +- Settings screen has no plan section + +## Goals / Non-Goals + +**Goals:** +- Introduce `planService.ts` to fetch and cache `GET /auth/me/plan` +- Display plan, text/voice quotas with progress bars, and reset date in Settings +- Handle HTTP `429` in text chat with an inline, localized message and CTA +- Handle WS close `4029` and error frame in voice chat with a modal and CTA +- Show a live `X-RateLimit-Remaining` counter in the chat input area; disable send at 0 +- Add all user-facing strings to `en.json` and `it.json` + +**Non-Goals:** +- In-app upgrade/payment flow (CTA navigates to Plan screen only) +- Admin plan management UI +- Push notification when quota resets +- Caching plan data across app restarts (re-fetch on open is sufficient) + +## Decisions + +### D1 — New `planService.ts` instead of extending existing services +Quota data is orthogonal to tasks and auth. A dedicated service keeps concerns separate and is easier to test. +Alternative considered: extending `authService.ts`. Rejected because auth service is already complex and quota data has its own refresh lifecycle. + +### D2 — Store remaining counter in React state, not AsyncStorage +`X-RateLimit-Remaining` is up-to-date after every message send. Persisting it across app restarts adds complexity for little gain; `GET /auth/me/plan` on next open gives accurate data. +Alternative: AsyncStorage persistence. Rejected as overkill. + +### D3 — Inline error message in chat (not toast) for 429 +The server spec explicitly requests an inline error message. This also provides a persistent, tappable CTA to the Plan screen. +Alternative: bottom toast. Rejected per server integration guide. + +### D4 — Voice quota modal (not inline) for close 4029 +Voice chat UI doesn't have a chat message thread. A modal is the appropriate pattern for a session-ending error. + +### D5 — Threshold `>= 9999` treated as unlimited +ENTERPRISE plan returns `999999`. Any value >= 9999 is treated as unlimited in the UI (counter hidden, send never disabled). + +### D6 — `X-RateLimit-Remaining` read from SSE/fetch response headers +`botservice.ts` already wraps the `/chat/text` call. The remaining count will be extracted from the response headers at that call site and returned alongside the message response so the caller can update UI state. + +## Risks / Trade-offs + +- [Risk] SSE streaming responses may not expose headers via the Fetch API in React Native → Mitigation: test on Android/iOS; fall back to `GET /auth/me/plan` if headers are inaccessible in the streaming path. +- [Risk] Plan screen navigation assumes a named route exists → Mitigation: verify route name in `RootStackParamList` before wiring CTAs; add route if missing. +- [Risk] WS close code `4029` may be swallowed by reconnect logic → Mitigation: check existing reconnect guard in `VoiceBotWebSocket` and add an explicit `4029` branch that skips reconnect. + +## Migration Plan + +No data migration needed. All changes are additive: +1. Add `planService.ts` +2. Update `botservice.ts` (header reading + 429 handling) +3. Update `VoiceBotWebSocket` in `botservice.ts` (4029 handling) +4. Add Plan section to Settings screen +5. Update BotChat screen (inline error + counter + disabled state) +6. Add i18n strings + +Rollback: revert the above files. No server-side changes required from the client side. + +## Open Questions + +- Is there an existing "Plan" or "Subscription" screen in the navigation stack, or must it be created? (Affects CTA navigation target.) +- Does the voice chat UI live in a dedicated screen or is it a modal within BotChat? (Affects where to show the modal.) diff --git a/openspec/changes/chat-quota-system/proposal.md b/openspec/changes/chat-quota-system/proposal.md new file mode 100644 index 0000000..884c321 --- /dev/null +++ b/openspec/changes/chat-quota-system/proposal.md @@ -0,0 +1,34 @@ +## Why + +The server now enforces monthly usage quotas on AI chat (text and voice) per user plan (FREE/PRO/ENTERPRISE). The client must surface quota status, handle exhaustion errors gracefully, and guide users toward upgrading — otherwise users will hit silent failures or confusing errors when their quota runs out. + +## What Changes + +- **New**: `GET /auth/me/plan` API call to fetch plan info and usage counters +- **New**: Plan & Usage UI section in Settings showing text/voice quotas with progress bars and reset date +- **Modified**: Text chat handler now catches HTTP `429` and shows an inline quota-exceeded message with CTA to Plan screen +- **Modified**: Voice WebSocket handler now catches close code `4029` and error frame `{"type":"error","message":"Voice request quota exceeded..."}`, showing a modal with CTA to Plan screen +- **New**: Live quota counter in chat UI reading `X-RateLimit-Remaining` from response headers; disables send button at 0 +- **New**: `planService.ts` service for fetching and caching plan/quota data + +## Capabilities + +### New Capabilities + +- `plan-usage`: Fetch and display user plan info, text/voice quota counters, and reset date from `GET /auth/me/plan` +- `chat-quota-enforcement`: Handle `429` HTTP errors in text chat and WS close code `4029` in voice chat with inline messages and Plan screen CTAs +- `quota-indicator`: Live remaining-messages counter in chat UI derived from `X-RateLimit-Remaining` response headers + +### Modified Capabilities + + + +## Impact + +- `src/services/botservice.ts`: Add header reading for `X-RateLimit-Remaining`; handle `429` in text send; handle WS close `4029` and error frame in voice +- `src/navigation/screens/Settings.tsx`: Add Plan & Usage section with progress bars +- `src/navigation/screens/BotChat.tsx` (or equivalent): Show inline quota error, soft warning, disabled send button +- `src/navigation/screens/VoiceChat.tsx` (or equivalent): Show voice quota modal +- New file: `src/services/planService.ts` — wraps `GET /auth/me/plan` +- `src/locales/en.json` / `src/locales/it.json`: Add quota-related strings +- No new native dependencies required diff --git a/openspec/changes/chat-quota-system/specs/chat-quota-enforcement/spec.md b/openspec/changes/chat-quota-system/specs/chat-quota-enforcement/spec.md new file mode 100644 index 0000000..45e9f63 --- /dev/null +++ b/openspec/changes/chat-quota-system/specs/chat-quota-enforcement/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Handle HTTP 429 in text chat +The text chat send handler SHALL catch HTTP `429` responses from `POST /chat/text` and display an inline error message in the chat thread. The message SHALL include the plan name and quota reset date. The system SHALL NOT retry automatically on 429. + +#### Scenario: Text quota exceeded +- **WHEN** the user sends a text message and the server responds with HTTP `429` +- **THEN** an inline bot-style message appears in the chat: "Hai raggiunto il limite mensile di messaggi per il tuo piano {PLAN}. I contatori si resettano il {RESET_DATE}." +- **AND** the message includes a tappable CTA that navigates to the Plan & Usage screen +- **AND** the send button remains accessible (user is not locked out permanently) + +#### Scenario: No automatic retry on 429 +- **WHEN** the server returns `429` +- **THEN** the client does NOT resend the message automatically + +### Requirement: Handle WebSocket close code 4029 in voice chat +The voice WebSocket handler SHALL detect the `{"type":"error","message":"Voice request quota exceeded..."}` frame and the close code `4029`, and SHALL display a quota-exceeded modal. The system SHALL NOT attempt automatic reconnection after a `4029` close. + +#### Scenario: Voice quota exceeded — error frame received +- **WHEN** the WebSocket receives a JSON frame with `type === "error"` and a message containing "quota exceeded" +- **THEN** the voice session ends and a modal is shown: "Hai esaurito le richieste vocali mensili per il piano {PLAN}. Puoi continuare a usare la chat testuale." +- **AND** the modal includes a CTA to navigate to the Plan & Usage screen + +#### Scenario: Voice quota exceeded — close code 4029 +- **WHEN** the WebSocket closes with code `4029` +- **THEN** the voice session ends and the same quota-exceeded modal is shown +- **AND** the client does NOT attempt to reconnect + +#### Scenario: Normal WebSocket close is unaffected +- **WHEN** the WebSocket closes with any code other than `4029` +- **THEN** existing reconnect and error-handling logic is unchanged diff --git a/openspec/changes/chat-quota-system/specs/plan-usage/spec.md b/openspec/changes/chat-quota-system/specs/plan-usage/spec.md new file mode 100644 index 0000000..a5885f4 --- /dev/null +++ b/openspec/changes/chat-quota-system/specs/plan-usage/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Fetch user plan and quota data +The system SHALL provide a `planService.ts` that calls `GET /auth/me/plan` with the user's Bearer token and API key, returning plan name, text/voice usage counters, and reset date. + +#### Scenario: Successful fetch +- **WHEN** `planService.getUserPlan()` is called with a valid token +- **THEN** it returns an object with `plan`, `text_messages_limit`, `text_messages_used`, `voice_requests_limit`, `voice_requests_used`, and `reset_date` + +#### Scenario: Network or auth failure +- **WHEN** the request fails (network error or 401) +- **THEN** `planService.getUserPlan()` throws an error that the caller can handle + +### Requirement: Display plan and usage in Settings +The Settings screen SHALL display a "Piano & Utilizzo" section that shows the user's current plan, text and voice usage progress bars, and the quota reset date. The section SHALL be populated by calling `planService.getUserPlan()` on screen mount. + +#### Scenario: Data loaded successfully +- **WHEN** the Settings screen mounts and the plan fetch succeeds +- **THEN** the screen displays the plan badge (e.g. "FREE"), two progress bars (text messages and voice requests) with used/limit labels, and the reset date formatted as a localized date string + +#### Scenario: FREE plan shown with upgrade CTA +- **WHEN** the fetched plan is `"FREE"` +- **THEN** an "Upgrade" CTA button is visible below the usage section + +#### Scenario: ENTERPRISE plan — unlimited display +- **WHEN** either `text_messages_limit` or `voice_requests_limit` is >= 9999 +- **THEN** the corresponding counter displays "Illimitato" instead of a numeric limit and the progress bar is hidden + +#### Scenario: Loading state +- **WHEN** the plan fetch is in progress +- **THEN** placeholder/skeleton UI is shown for the usage section + +#### Scenario: Fetch error +- **WHEN** the plan fetch fails +- **THEN** a retry button or error message is shown; the rest of Settings remains usable diff --git a/openspec/changes/chat-quota-system/specs/quota-indicator/spec.md b/openspec/changes/chat-quota-system/specs/quota-indicator/spec.md new file mode 100644 index 0000000..56dcb1e --- /dev/null +++ b/openspec/changes/chat-quota-system/specs/quota-indicator/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Live remaining-messages counter in chat UI +After each successful text message send, the chat screen SHALL read the `X-RateLimit-Remaining` header from the response, store it in local state, and display a subtle counter near the chat input. The counter SHALL be hidden for ENTERPRISE users (limit >= 9999). + +#### Scenario: Counter updates after successful send +- **WHEN** `POST /chat/text` returns `200 OK` with an `X-RateLimit-Remaining` header +- **THEN** the counter in the chat UI updates to show "{N} messaggi rimasti questo mese" + +#### Scenario: Counter hidden for ENTERPRISE users +- **WHEN** the user's `text_messages_limit` is >= 9999 +- **THEN** no remaining-messages counter is displayed + +#### Scenario: Soft warning at low quota +- **WHEN** `X-RateLimit-Remaining` is <= 5 +- **THEN** the counter text changes to a warning style: "Ti restano solo {N} messaggi questo mese." + +### Requirement: Disable send button at zero remaining +When `X-RateLimit-Remaining` reaches `0`, the chat send button SHALL be disabled and the exhausted state SHALL be shown (matching the inline error from the chat-quota-enforcement spec). + +#### Scenario: Send disabled at quota zero +- **WHEN** `X-RateLimit-Remaining` is `0` (or the last response returned `429`) +- **THEN** the send button is visually disabled and tapping it does not send a message +- **AND** the inline quota-exceeded message is visible in the chat thread + +#### Scenario: Send re-enabled after plan refresh +- **WHEN** the user navigates to the Plan & Usage screen and returns to chat +- **THEN** if the quota has been refreshed (new month), the send button becomes active again diff --git a/openspec/changes/chat-quota-system/tasks.md b/openspec/changes/chat-quota-system/tasks.md new file mode 100644 index 0000000..1298186 --- /dev/null +++ b/openspec/changes/chat-quota-system/tasks.md @@ -0,0 +1,32 @@ +## 1. Plan Service + +- [x] 1.1 Create `src/services/planService.ts` with `getUserPlan()` that calls `GET /auth/me/plan` using the existing axios instance (with Bearer token and API key headers) +- [x] 1.2 Define and export `UserPlan` TypeScript interface matching the API response fields +- [x] 1.3 Add localized strings for plan/quota UI to `src/locales/en.json` and `src/locales/it.json` (plan badge labels, progress bar labels, reset date label, upgrade CTA, "Illimitato" text) + +## 2. Settings — Plan & Usage Section + +- [x] 2.1 Add a "Piano & Utilizzo" section to `src/navigation/screens/Settings.tsx` that calls `planService.getUserPlan()` on mount +- [x] 2.2 Render plan badge (FREE / PRO / ENTERPRISE), two progress bars (text messages and voice requests) with used/limit labels, and formatted reset date +- [x] 2.3 Show "Upgrade" CTA button when `plan === "FREE"` +- [x] 2.4 Display "Illimitato" and hide progress bar when limit >= 9999 +- [x] 2.5 Handle loading and error states in the Settings section (skeleton UI + retry button) + +## 3. Text Chat — 429 Handling + +- [x] 3.1 In the text message send handler (BotChat screen or `botservice.ts`), catch HTTP `429` responses and extract plan name and reset date for the error message +- [x] 3.2 Insert an inline bot-style error message into the chat thread with the localized quota-exceeded text and a tappable CTA that navigates to the Plan & Usage screen (Settings) +- [x] 3.3 Ensure no automatic retry occurs on 429 + +## 4. Quota Indicator in Chat UI + +- [x] 4.1 In `botservice.ts`, extract `X-RateLimit-Remaining` from the `POST /chat/text` response headers and return it alongside the message data +- [x] 4.2 In the BotChat screen, store `remainingMessages` in local state and update it after each successful send +- [x] 4.3 Render the remaining-messages counter near the chat input; apply warning style when <= 5; hide for ENTERPRISE (limit >= 9999) +- [x] 4.4 Disable the send button and show the exhausted state when `remainingMessages === 0` (or last response was 429) + +## 5. Voice Chat — 4029 Handling + +- [x] 5.1 In `VoiceBotWebSocket` (`src/services/botservice.ts`), add a handler for incoming JSON frames with `type === "error"` containing a quota-exceeded message +- [x] 5.2 Add a handler for WebSocket close code `4029` that suppresses automatic reconnection and invokes a `onVoiceQuotaExceeded` callback +- [x] 5.3 In the voice chat UI (VoiceChat screen or BotChat voice modal), show a modal with the localized voice-quota-exceeded message and a CTA to the Plan & Usage screen when `onVoiceQuotaExceeded` fires diff --git a/src/components/BotChat/ChatInput.tsx b/src/components/BotChat/ChatInput.tsx index 6d53371..195dcf0 100644 --- a/src/components/BotChat/ChatInput.tsx +++ b/src/components/BotChat/ChatInput.tsx @@ -6,13 +6,15 @@ import VoiceRecordButton from './VoiceRecordButton'; export interface ExtendedChatInputProps extends ChatInputProps { modelType?: 'base' | 'advanced'; + isDisabled?: boolean; } -const ChatInput: React.FC = ({ - onSendMessage, - onSendVoiceMessage, - style, - modelType = 'base' +const ChatInput: React.FC = ({ + onSendMessage, + onSendVoiceMessage, + style, + modelType = 'base', + isDisabled = false, }) => { const [inputText, setInputText] = useState(''); const [inputHeight, setInputHeight] = useState(44); @@ -104,7 +106,7 @@ const ChatInput: React.FC = ({ onContentSizeChange={handleContentSizeChange} blurOnSubmit={false} textAlignVertical="top" - editable={!isRecording} + editable={!isRecording && !isDisabled} /> @@ -119,13 +121,13 @@ const ChatInput: React.FC = ({ diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index d18ea81..8e0a29b 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -16,6 +16,9 @@ import { StatusBar } from 'expo-status-bar'; import Svg, { Path, Defs, RadialGradient, Stop } from "react-native-svg"; import { Ionicons } from "@expo/vector-icons"; import { useVoiceChat, ActiveTool } from '../../hooks/useVoiceChat'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useTranslation } from 'react-i18next'; +import { RootStackParamList } from '../../types'; export interface VoiceChatModalProps { @@ -226,6 +229,8 @@ const VoiceChatModal: React.FC = ({ onOpenCalendar, }) => { const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const { t } = useTranslation(); const { state, error, @@ -237,6 +242,7 @@ const VoiceChatModal: React.FC = ({ transcripts, activeTools, isMuted, + isVoiceQuotaExceeded, connect, disconnect, requestPermissions, @@ -353,6 +359,32 @@ const VoiceChatModal: React.FC = ({ await disconnect(); }; + // Show quota-exceeded alert when voice quota is exhausted + useEffect(() => { + if (isVoiceQuotaExceeded && visible) { + const message = t('planUsage.voiceQuotaExceeded', { plan: 'FREE' }); + Alert.alert( + t('planUsage.quotaZero'), + message, + [ + { + text: t('common.buttons.cancel'), + style: 'cancel', + onPress: handleClose, + }, + { + text: t('planUsage.goToPlan'), + onPress: () => { + handleClose(); + navigation.navigate('Settings'); + }, + }, + ], + { cancelable: false } + ); + } + }, [isVoiceQuotaExceeded, visible]); // eslint-disable-line react-hooks/exhaustive-deps + // Label testo stato const getStateLabel = (): string => { switch (state) { diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index c14a83b..60be61e 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -58,6 +58,7 @@ export function useVoiceChat() { const [hasPermissions, setHasPermissions] = useState(false); const [chunksReceived, setChunksReceived] = useState(0); const [isMuted, setIsMuted] = useState(false); + const [isVoiceQuotaExceeded, setIsVoiceQuotaExceeded] = useState(false); // Trascrizioni e tool const [transcripts, setTranscripts] = useState([]); @@ -119,6 +120,7 @@ export function useVoiceChat() { onToolOutput: (...args) => websocketCallbacksRef.current.onToolOutput?.(...args), onDone: (...args) => websocketCallbacksRef.current.onDone?.(...args), onError: (...args) => websocketCallbacksRef.current.onError?.(...args), + onVoiceQuotaExceeded: () => websocketCallbacksRef.current.onVoiceQuotaExceeded?.(), }).current; // Aggiorna il ref ad ogni render con le callback che chiudono su stato/ref correnti @@ -291,7 +293,13 @@ export function useVoiceChat() { if (!isMountedRef.current) return; setError(errorMessage); setState('error'); - } + }, + + onVoiceQuotaExceeded: () => { + if (!isMountedRef.current) return; + setIsVoiceQuotaExceeded(true); + setState('error'); + }, }; /** @@ -331,6 +339,7 @@ export function useVoiceChat() { setState('connecting'); setError(null); + setIsVoiceQuotaExceeded(false); setTranscripts([]); setActiveTools([]); setChunksReceived(0); @@ -562,6 +571,7 @@ export function useVoiceChat() { setState('idle'); setServerStatus(null); setError(null); + setIsVoiceQuotaExceeded(false); setTranscripts([]); setActiveTools([]); setRecordingDuration(0); @@ -650,6 +660,7 @@ export function useVoiceChat() { hasPermissions, chunksReceived, isMuted, + isVoiceQuotaExceeded, // Trascrizioni e tool transcripts, diff --git a/src/locales/en.json b/src/locales/en.json index 301a1c4..03b98fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -908,6 +908,23 @@ "getStarted": "Get Started" } }, + "planUsage": { + "sectionTitle": "Plan & Usage", + "plan": "Plan", + "unlimited": "Unlimited", + "textMessages": "Text messages", + "voiceRequests": "Voice requests", + "resetsOn": "Resets on {{date}}", + "upgrade": "Upgrade plan", + "loading": "Loading plan info...", + "error": "Unable to load plan information. Tap to retry.", + "quotaExceeded": "You have reached the monthly message limit for your {{plan}} plan. Counters reset on {{date}}.", + "voiceQuotaExceeded": "You have used all monthly voice requests for the {{plan}} plan. You can still use the text chat.", + "goToPlan": "View plan", + "remaining": "{{n}} messages left this month", + "remainingWarning": "Only {{n}} messages left this month", + "quotaZero": "Monthly limit reached" + }, "memorySettings": { "loading": "Loading memories...", "enableMemory": "Enable chatbot memory", diff --git a/src/locales/it.json b/src/locales/it.json index af9b19b..8d83aea 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -908,6 +908,23 @@ "getStarted": "Inizia" } }, + "planUsage": { + "sectionTitle": "Piano & Utilizzo", + "plan": "Piano", + "unlimited": "Illimitato", + "textMessages": "Messaggi di testo", + "voiceRequests": "Richieste vocali", + "resetsOn": "Si azzera il {{date}}", + "upgrade": "Aggiorna piano", + "loading": "Caricamento piano...", + "error": "Impossibile caricare le informazioni sul piano. Tocca per riprovare.", + "quotaExceeded": "Hai raggiunto il limite mensile di messaggi per il tuo piano {{plan}}. I contatori si resettano il {{date}}.", + "voiceQuotaExceeded": "Hai esaurito le richieste vocali mensili per il piano {{plan}}. Puoi continuare a usare la chat testuale.", + "goToPlan": "Vai al piano", + "remaining": "{{n}} messaggi rimasti questo mese", + "remainingWarning": "Ti restano solo {{n}} messaggi questo mese", + "quotaZero": "Limite mensile raggiunto" + }, "memorySettings": { "loading": "Caricamento memorie...", "enableMemory": "Abilita memoria chatbot", diff --git a/src/navigation/screens/AISettings.tsx b/src/navigation/screens/AISettings.tsx index 2e73af6..a86f2cb 100644 --- a/src/navigation/screens/AISettings.tsx +++ b/src/navigation/screens/AISettings.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { StyleSheet, View, TouchableOpacity, ScrollView, - Animated, + Alert, + ActivityIndicator, Text, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -14,38 +15,138 @@ import { RootStackParamList } from '../../types'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { getUserPlan, isUnlimitedPlan, UserPlan } from '../../services/planService'; const AI_MODEL_KEY = 'ai_model_tier'; type ModelTier = 'base' | 'advanced'; -// Demo usage data -const USAGE_USED = 348; -const USAGE_LIMIT = 500; -const USAGE_RATIO = USAGE_USED / USAGE_LIMIT; - export default function AISettings() { const navigation = useNavigation>(); const { t } = useTranslation(); const [model, setModel] = useState('base'); - const barAnim = useRef(new Animated.Value(0)).current; + + // Plan & Usage state + const [planData, setPlanData] = useState(null); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState(false); + + const loadPlan = useCallback(async () => { + try { + setPlanLoading(true); + setPlanError(false); + const data = await getUserPlan(); + setPlanData(data); + } catch { + setPlanError(true); + } finally { + setPlanLoading(false); + } + }, []); useEffect(() => { AsyncStorage.getItem(AI_MODEL_KEY).then((val) => { if (val === 'advanced' || val === 'base') setModel(val); }); - Animated.timing(barAnim, { - toValue: USAGE_RATIO, - duration: 900, - useNativeDriver: false, - }).start(); - }, []); + loadPlan(); + }, [loadPlan]); const handleModelSelect = async (tier: ModelTier) => { setModel(tier); await AsyncStorage.setItem(AI_MODEL_KEY, tier); }; - const barColor = USAGE_RATIO > 0.85 ? '#dc3545' : USAGE_RATIO > 0.6 ? '#f4a322' : '#000000'; + const formatResetDate = (dateStr: string): string => { + try { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }); + } catch { + return dateStr; + } + }; + + const renderProgressBar = (used: number, limit: number) => { + const fraction = limit > 0 ? Math.min(used / limit, 1) : 0; + const isNearLimit = fraction >= 0.8; + return ( + + + + ); + }; + + const renderPlanSection = () => { + if (planLoading) { + return ( + + + {t('planUsage.loading')} + + ); + } + + if (planError || !planData) { + return ( + + {t('planUsage.error')} + + ); + } + + const textUnlimited = isUnlimitedPlan(planData.text_messages_limit); + const voiceUnlimited = isUnlimitedPlan(planData.voice_requests_limit); + + return ( + + + + {planData.plan} + + + {t('planUsage.resetsOn', { date: formatResetDate(planData.reset_date) })} + + + + + + {t('planUsage.textMessages')} + + {textUnlimited + ? t('planUsage.unlimited') + : `${planData.text_messages_used} / ${planData.text_messages_limit}`} + + + {!textUnlimited && renderProgressBar(planData.text_messages_used, planData.text_messages_limit)} + + + + + {t('planUsage.voiceRequests')} + + {voiceUnlimited + ? t('planUsage.unlimited') + : `${planData.voice_requests_used} / ${planData.voice_requests_limit}`} + + + {!voiceUnlimited && renderProgressBar(planData.voice_requests_used, planData.voice_requests_limit)} + + + {planData.plan === 'FREE' && ( + Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + > + {t('planUsage.upgrade')} + + )} + + ); + }; return ( @@ -92,37 +193,10 @@ export default function AISettings() { {/* ── USAGE ── */} - {t('aiSettings.sections.usage')} - {t('aiSettings.sections.usageDesc')} + {t('planUsage.sectionTitle')} - - - - {t('aiSettings.usage.messages')} - - {USAGE_USED} - / {USAGE_LIMIT} - - - - - - - - - {t('aiSettings.usage.remaining', { n: USAGE_LIMIT - USAGE_USED })} - + + {renderPlanSection()} {/* ── FEATURES ── */} @@ -277,57 +351,100 @@ const styles = StyleSheet.create({ marginTop: 2, }, - // Usage card - usageCard: { - marginHorizontal: 20, - padding: 16, + // Plan & Usage card + planCardWrapper: { + paddingHorizontal: 20, + }, + planCard: { backgroundColor: '#f8f9fa', - borderRadius: 12, + borderRadius: 16, + padding: 16, borderWidth: 1, borderColor: '#e1e5e9', }, - usageRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'baseline', - marginBottom: 10, - }, - usageLabel: { + planLoadingText: { fontSize: 14, - color: '#495057', + color: '#666666', fontFamily: 'System', - fontWeight: '500', + marginTop: 8, + textAlign: 'center', }, - usageCount: { + planErrorText: { fontSize: 14, + color: '#666666', fontFamily: 'System', + textAlign: 'center', + }, + planBadgeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, + }, + planBadge: { + backgroundColor: '#000000', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 4, }, - usageUsed: { - fontSize: 16, + planBadgeText: { + color: '#ffffff', + fontSize: 12, fontWeight: '600', - color: '#000000', fontFamily: 'System', + letterSpacing: 0.5, + }, + resetDateText: { + fontSize: 12, + color: '#666666', + fontFamily: 'System', + }, + usageRow: { + marginBottom: 12, + }, + usageLabelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + }, + usageLabel: { + fontSize: 14, + color: '#333333', + fontFamily: 'System', + fontWeight: '400', }, - usageOf: { + usageCount: { fontSize: 14, - color: '#6c757d', + color: '#333333', fontFamily: 'System', + fontWeight: '500', }, - barTrack: { + progressBarTrack: { height: 6, - backgroundColor: '#dee2e6', + backgroundColor: '#e1e5e9', borderRadius: 3, overflow: 'hidden', }, - barFill: { - height: '100%', + progressBarFill: { + height: 6, + backgroundColor: '#000000', borderRadius: 3, }, - usageHint: { - fontSize: 12, - color: '#6c757d', + progressBarWarning: { + backgroundColor: '#FF6B35', + }, + upgradeButton: { + marginTop: 12, + backgroundColor: '#000000', + borderRadius: 12, + paddingVertical: 10, + alignItems: 'center', + }, + upgradeButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', fontFamily: 'System', - marginTop: 8, }, // Feature links diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index b9cf048..9b68253 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -1,6 +1,6 @@ import { Text } from '@react-navigation/elements'; -import React from 'react'; -import { StyleSheet, View, TouchableOpacity, ScrollView, Alert } from 'react-native'; +import React, { useState, useEffect, useCallback } from 'react'; +import { StyleSheet, View, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { useNavigation, NavigationProp } from '@react-navigation/native'; @@ -10,12 +10,35 @@ import { useTranslation } from 'react-i18next'; import { useTutorialContext } from '../../contexts/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TUTORIAL_STORAGE_KEY } from '../../constants/tutorialContent'; +import { getUserPlan, isUnlimitedPlan, UserPlan } from '../../services/planService'; export default function Settings() { const navigation = useNavigation>(); const { t } = useTranslation(); const { startTutorial } = useTutorialContext(); + // Plan & Usage state + const [planData, setPlanData] = useState(null); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState(false); + + const loadPlan = useCallback(async () => { + try { + setPlanLoading(true); + setPlanError(false); + const data = await getUserPlan(); + setPlanData(data); + } catch { + setPlanError(true); + } finally { + setPlanLoading(false); + } + }, []); + + useEffect(() => { + loadPlan(); + }, [loadPlan]); + const handleRestartTutorial = async () => { try { await AsyncStorage.removeItem(TUTORIAL_STORAGE_KEY); @@ -30,6 +53,103 @@ export default function Settings() { } }; + const formatResetDate = (dateStr: string): string => { + try { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }); + } catch { + return dateStr; + } + }; + + const renderProgressBar = (used: number, limit: number) => { + const fraction = limit > 0 ? Math.min(used / limit, 1) : 0; + const isNearLimit = fraction >= 0.8; + return ( + + + + ); + }; + + const renderPlanSection = () => { + if (planLoading) { + return ( + + + {t('planUsage.loading')} + + ); + } + + if (planError || !planData) { + return ( + + {t('planUsage.error')} + + ); + } + + const textUnlimited = isUnlimitedPlan(planData.text_messages_limit); + const voiceUnlimited = isUnlimitedPlan(planData.voice_requests_limit); + + return ( + + {/* Plan badge */} + + + {planData.plan} + + + {t('planUsage.resetsOn', { date: formatResetDate(planData.reset_date) })} + + + + {/* Text messages */} + + + {t('planUsage.textMessages')} + + {textUnlimited + ? t('planUsage.unlimited') + : `${planData.text_messages_used} / ${planData.text_messages_limit}`} + + + {!textUnlimited && renderProgressBar(planData.text_messages_used, planData.text_messages_limit)} + + + {/* Voice requests */} + + + {t('planUsage.voiceRequests')} + + {voiceUnlimited + ? t('planUsage.unlimited') + : `${planData.voice_requests_used} / ${planData.voice_requests_limit}`} + + + {!voiceUnlimited && renderProgressBar(planData.voice_requests_used, planData.voice_requests_limit)} + + + {/* Upgrade CTA for FREE */} + {planData.plan === 'FREE' && ( + Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + > + {t('planUsage.upgrade')} + + )} + + ); + }; + return ( @@ -51,6 +171,14 @@ export default function Settings() { + {/* Plan & Usage Section */} + + {t('planUsage.sectionTitle')} + + + {renderPlanSection()} + + {/* AI Section */} {t('settings.sections.ai')} @@ -198,4 +326,99 @@ const styles = StyleSheet.create({ color: '#000000', fontFamily: 'System', }, + // Plan & Usage card + planCardWrapper: { + paddingHorizontal: 20, + }, + planCard: { + backgroundColor: '#f8f9fa', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: '#e1e5e9', + }, + planLoadingText: { + fontSize: 14, + color: '#666666', + fontFamily: 'System', + marginTop: 8, + textAlign: 'center', + }, + planErrorText: { + fontSize: 14, + color: '#666666', + fontFamily: 'System', + textAlign: 'center', + }, + planBadgeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, + }, + planBadge: { + backgroundColor: '#000000', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 4, + }, + planBadgeText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '600', + fontFamily: 'System', + letterSpacing: 0.5, + }, + resetDateText: { + fontSize: 12, + color: '#666666', + fontFamily: 'System', + }, + usageRow: { + marginBottom: 12, + }, + usageLabelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + }, + usageLabel: { + fontSize: 14, + color: '#333333', + fontFamily: 'System', + fontWeight: '400', + }, + usageCount: { + fontSize: 14, + color: '#333333', + fontFamily: 'System', + fontWeight: '500', + }, + progressBarTrack: { + height: 6, + backgroundColor: '#e1e5e9', + borderRadius: 3, + overflow: 'hidden', + }, + progressBarFill: { + height: 6, + backgroundColor: '#000000', + borderRadius: 3, + }, + progressBarWarning: { + backgroundColor: '#FF6B35', + }, + upgradeButton: { + marginTop: 12, + backgroundColor: '#000000', + borderRadius: 12, + paddingVertical: 10, + alignItems: 'center', + }, + upgradeButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + fontFamily: 'System', + }, }); diff --git a/src/services/planService.ts b/src/services/planService.ts new file mode 100644 index 0000000..ccd6367 --- /dev/null +++ b/src/services/planService.ts @@ -0,0 +1,24 @@ +import axiosInstance from './axiosInstance'; + +export interface UserPlan { + plan: string; + text_messages_limit: number; + text_messages_used: number; + voice_requests_limit: number; + voice_requests_used: number; + reset_date: string; // ISO 8601 date, e.g. "2026-05-01" +} + +/** + * Fetches the current user's plan and monthly usage from GET /auth/me/plan. + * Auth token is injected automatically by the axios interceptor. + */ +export async function getUserPlan(): Promise { + const response = await axiosInstance.get('/auth/me/plan'); + return response.data; +} + +/** Returns true when the plan should be treated as unlimited (ENTERPRISE). */ +export function isUnlimitedPlan(limit: number): boolean { + return limit >= 9999; +} diff --git a/src/services/textBotService.ts b/src/services/textBotService.ts index 8eea7e0..f5f2a81 100644 --- a/src/services/textBotService.ts +++ b/src/services/textBotService.ts @@ -32,7 +32,7 @@ export async function sendMessageToBot( modelType: "base" | "advanced" = "base", onStreamChunk?: StreamingCallback, chatId?: string -): Promise<{text: string, toolWidgets: ToolWidget[], chat_id?: string, is_new?: boolean}> { +): Promise<{text: string, toolWidgets: ToolWidget[], chat_id?: string, is_new?: boolean, quotaExceeded?: boolean, remainingMessages?: number, rateLimitReset?: number}> { try { // Verifica che l'utente sia autenticato const token = await getValidToken(); @@ -70,9 +70,22 @@ export async function sendMessageToBot( }); if (!response.ok) { + if (response.status === 429) { + const rateLimitReset = response.headers.get('X-RateLimit-Reset'); + return { + text: '', + toolWidgets: [], + quotaExceeded: true, + rateLimitReset: rateLimitReset ? parseInt(rateLimitReset, 10) : undefined, + }; + } throw new Error(`HTTP error! status: ${response.status}`); } + // Read remaining quota from headers before consuming body + const rateLimitRemainingHeader = response.headers.get('X-RateLimit-Remaining'); + const remainingMessages = rateLimitRemainingHeader !== null ? parseInt(rateLimitRemainingHeader, 10) : undefined; + if (!response.body) { console.log(response) throw new Error("Nessun body nella risposta"); @@ -277,6 +290,7 @@ export async function sendMessageToBot( toolWidgets: Array.from(toolWidgetsMap.values()), chat_id: receivedChatId, is_new: isNewChat, + remainingMessages, }; } catch (error: any) { diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index e9595ae..6b62aa4 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -122,6 +122,8 @@ export interface VoiceChatCallbacks { onAuthenticationFailed?: (error: string) => void; onReady?: () => void; onDone?: () => void; + /** Called when the server closes the connection with code 4029 (voice quota exceeded). */ + onVoiceQuotaExceeded?: () => void; } /** @@ -246,6 +248,13 @@ export class VoiceBotWebSocket { _vLog(`WS chiuso — code=${event.code} reason="${event.reason}" reconnectAttempts=${this.reconnectAttempts}`); this.callbacks.onConnectionClose?.(); + if (event.code === 4029) { + // Voice quota exceeded — do not reconnect + _vLog('WS chiuso con 4029 — quota vocale esaurita, nessun reconnect'); + this.callbacks.onVoiceQuotaExceeded?.(); + return; + } + if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { this.attemptReconnect(); } @@ -472,6 +481,14 @@ export class VoiceBotWebSocket { private handleErrorResponse(response: VoiceErrorResponse): void { if (!response.message) return; + // Check for voice quota exceeded before auth state handling + if (response.message.toLowerCase().includes('quota exceeded')) { + _vLog('Quota vocale esaurita (error frame)'); + trackVoiceChatError('voice_quota_exceeded'); + this.callbacks.onVoiceQuotaExceeded?.(); + return; + } + if (this.authState === WebSocketAuthState.AUTHENTICATING) { this.authState = WebSocketAuthState.FAILED; this.clearAuthTimeout();