@qafka/react-native 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.6.0] — 2026-09-15
11
+
12
+ ### Added
13
+
14
+ - Unavailable state: when the assistant for a project is temporarily unavailable, the chat screen now shows a neutral, localized message ("The assistant is currently unavailable." / Turkish when `locale` starts with `tr`) instead of an error. The input is hidden and no error bubble or initialization banner is shown.
15
+ - New `onUnavailable` prop on `<Qafka />`, called when the assistant becomes unavailable (at start-up or mid-conversation). Use it to hide or disable your own entry point to the chat.
16
+ - `SDKStatusType` gains `'unavailable'`, delivered through `onStatusChange` and `QafkaSDK.getStatus()`.
17
+ - Localized greeting: when the `locale` prop is set, the project theme request now includes it, so the greeting and initial message are returned in that language when a translation exists. Without `locale` the request is unchanged.
18
+
19
+ ### Changed
20
+
21
+ - `QafkaSDK.initialize()` now waits for the project theme request to settle before resolving, so the final status (`'ready'` or `'unavailable'`) is known when it returns. `onError` is not called for the unavailable state.
22
+ - If you `switch` exhaustively over `SDKStatusType` in TypeScript, add a case for `'unavailable'`.
23
+
24
+ ### Documentation
25
+
26
+ - Clarified how the `locale`, `context` and `contextDescription` props are used, with examples, and documented `onUnavailable` in the tool authoring guide.
27
+
10
28
  ## [2.5.0] — 2026-07-19
11
29
 
12
30
  ### Added
@@ -13,6 +13,7 @@ export declare class QafkaSDK {
13
13
  private conversationManager;
14
14
  private navigationHandler;
15
15
  private themePrefetchPromise;
16
+ private themePrefetchError;
16
17
  private constructor();
17
18
  /**
18
19
  * Get singleton instance
@@ -25,8 +26,9 @@ export declare class QafkaSDK {
25
26
  /**
26
27
  * developer-driven locale (BCP 47 — e.g. "tr", "en", "tr-TR").
27
28
  * Forwarded to BackendService so it lands in `sdkContext.locale` on every
28
- * outgoing chat request, which the server then echoes into the user-message
29
- * context prefix. Same value should drive UI strings on the SDK side.
29
+ * outgoing chat request — the language the assistant answers in. Explicit
30
+ * only: never derived from the device language, and it leaves the SDK's own
31
+ * UI strings untouched.
30
32
  *
31
33
  * Pass `null` to clear (e.g. when switching back to instructions-default).
32
34
  * Calling before initialize() is a no-op; pass `locale` via SDKConfig instead.
@@ -155,6 +157,15 @@ export declare class QafkaSDK {
155
157
  */
156
158
  getStatus(): SDKStatusType;
157
159
  /**
160
+ * Internal — not part of the documented public surface. Flips the SDK to
161
+ * the 'unavailable' status mid-session (e.g. a chat request comes back
162
+ * with `code === 'PROJECT_UNAVAILABLE'` after the project was already
163
+ * initialized successfully). Notifies the same `onStatusChange` listener
164
+ * `initialize()` uses, so `useSDK` reacts identically regardless of
165
+ * whether unavailability was detected at init time or mid-session.
166
+ */
167
+ markUnavailable(): void;
168
+ /**
158
169
  * Get SDK configuration
159
170
  */
160
171
  getConfig(): SDKConfig | null;
package/dist/QafkaSDK.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getSDK = exports.QafkaSDK = void 0;
4
4
  const storage_1 = require("./services/storage");
5
5
  const BackendService_1 = require("./services/BackendService");
6
+ const projectUnavailable_1 = require("./services/projectUnavailable");
6
7
  const AttestationManager_1 = require("./services/AttestationManager");
7
8
  const ConversationManager_1 = require("./services/ConversationManager");
8
9
  const NavigationHandler_1 = require("./services/NavigationHandler");
@@ -24,6 +25,11 @@ class QafkaSDK {
24
25
  // ChatPage can render the branded greeting without waiting for SDK ready.
25
26
  // See `useProjectTheme` which awaits this promise.
26
27
  themePrefetchPromise = null;
28
+ // Captured alongside themePrefetchPromise (which swallows its rejection so
29
+ // useProjectTheme always gets a settled promise). initialize() inspects
30
+ // this after the prefetch settles to decide whether the final status is
31
+ // 'ready' or 'unavailable'.
32
+ themePrefetchError = null;
27
33
  constructor() { }
28
34
  /**
29
35
  * Get singleton instance
@@ -78,7 +84,10 @@ class QafkaSDK {
78
84
  // needs are not ready yet — firing the prefetch now would fail, blank out
79
85
  // the greeting, and need a retry. Deferred to after init below.
80
86
  if (apiKey) {
81
- this.themePrefetchPromise = this.prefetchAndCacheTheme().catch(() => null);
87
+ this.themePrefetchPromise = this.prefetchAndCacheTheme().catch((err) => {
88
+ this.themePrefetchError = err;
89
+ return null;
90
+ });
82
91
  }
83
92
  // Save config to storage
84
93
  await storage_1.storage.setItem('@qafka/config', JSON.stringify(config));
@@ -103,7 +112,10 @@ class QafkaSDK {
103
112
  // credentials it needs and the prefetch will succeed on first try,
104
113
  // hydrating useProjectTheme before the chat surface paints.
105
114
  if (!apiKey && !this.themePrefetchPromise) {
106
- this.themePrefetchPromise = this.prefetchAndCacheTheme().catch(() => null);
115
+ this.themePrefetchPromise = this.prefetchAndCacheTheme().catch((err) => {
116
+ this.themePrefetchError = err;
117
+ return null;
118
+ });
107
119
  }
108
120
  }
109
121
  catch (error) {
@@ -119,9 +131,19 @@ class QafkaSDK {
119
131
  throw error;
120
132
  }
121
133
  }
122
- this.status = 'ready';
134
+ // Decide the final status after the theme prefetch settles — a
135
+ // ProjectUnavailableError there means the project is suspended, which
136
+ // takes priority over an otherwise-successful attestation. This never
137
+ // rejects: the prefetch promise already swallows its own rejection
138
+ // (see themePrefetchError above).
139
+ if (this.themePrefetchPromise) {
140
+ await this.themePrefetchPromise;
141
+ }
142
+ this.status = this.themePrefetchError instanceof projectUnavailable_1.ProjectUnavailableError
143
+ ? 'unavailable'
144
+ : 'ready';
123
145
  if (config.onStatusChange) {
124
- config.onStatusChange('ready');
146
+ config.onStatusChange(this.status);
125
147
  }
126
148
  }
127
149
  catch (error) {
@@ -146,8 +168,9 @@ class QafkaSDK {
146
168
  /**
147
169
  * developer-driven locale (BCP 47 — e.g. "tr", "en", "tr-TR").
148
170
  * Forwarded to BackendService so it lands in `sdkContext.locale` on every
149
- * outgoing chat request, which the server then echoes into the user-message
150
- * context prefix. Same value should drive UI strings on the SDK side.
171
+ * outgoing chat request — the language the assistant answers in. Explicit
172
+ * only: never derived from the device language, and it leaves the SDK's own
173
+ * UI strings untouched.
151
174
  *
152
175
  * Pass `null` to clear (e.g. when switching back to instructions-default).
153
176
  * Calling before initialize() is a no-op; pass `locale` via SDKConfig instead.
@@ -444,6 +467,20 @@ class QafkaSDK {
444
467
  return this.status;
445
468
  }
446
469
  /**
470
+ * Internal — not part of the documented public surface. Flips the SDK to
471
+ * the 'unavailable' status mid-session (e.g. a chat request comes back
472
+ * with `code === 'PROJECT_UNAVAILABLE'` after the project was already
473
+ * initialized successfully). Notifies the same `onStatusChange` listener
474
+ * `initialize()` uses, so `useSDK` reacts identically regardless of
475
+ * whether unavailability was detected at init time or mid-session.
476
+ */
477
+ markUnavailable() {
478
+ this.status = 'unavailable';
479
+ if (this.config?.onStatusChange) {
480
+ this.config.onStatusChange('unavailable');
481
+ }
482
+ }
483
+ /**
447
484
  * Get SDK configuration
448
485
  */
449
486
  getConfig() {
@@ -480,6 +517,7 @@ class QafkaSDK {
480
517
  this.subProjectId = null;
481
518
  this.config = null;
482
519
  this.themePrefetchPromise = null;
520
+ this.themePrefetchError = null;
483
521
  this.status = 'uninitialized';
484
522
  // Only clear the shared singleton slot when WE are still the current
485
523
  // instance. An outgoing component unmounting must not null a newer
@@ -19,6 +19,12 @@ export interface ChatPageProps {
19
19
  } | null;
20
20
  sdkReady: boolean;
21
21
  sdkError: string | null;
22
+ /** True when the project is unavailable (suspended). Replaces the whole
23
+ * chat surface (messages, composer, error banner) with a neutral, centered
24
+ * message instead. */
25
+ unavailable?: boolean;
26
+ /** Forwarded to `unavailableMessage()` for the neutral copy above. */
27
+ locale?: string;
22
28
  theme: Theme;
23
29
  handleSend: (text: string) => void;
24
30
  handleActionClick: (action: any) => void;
@@ -41,4 +47,4 @@ export interface ChatPageProps {
41
47
  cardHostCallbacks?: import('../cards/cta/types').CardCTAHostCallbacks;
42
48
  cardLifecycle?: import('../cards/cta/types').CardCTALifecycle;
43
49
  }
44
- export declare function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, toolStatus, sdkReady, sdkError, theme, handleSend, handleActionClick, handleExternalSuggestionPress, flatListRef, placeholder, maxMessageLength, showTimestamps, greetingMessage, apiGreeting, isInitialMessageEnabled, initialMessageText, componentRegistry, onBack, onClose, BackComponent, CloseComponent, NavigationButtonComponent, cardHostCallbacks, cardLifecycle, }: ChatPageProps): React.JSX.Element;
50
+ export declare function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, toolStatus, sdkReady, sdkError, unavailable, locale, theme, handleSend, handleActionClick, handleExternalSuggestionPress, flatListRef, placeholder, maxMessageLength, showTimestamps, greetingMessage, apiGreeting, isInitialMessageEnabled, initialMessageText, componentRegistry, onBack, onClose, BackComponent, CloseComponent, NavigationButtonComponent, cardHostCallbacks, cardLifecycle, }: ChatPageProps): React.JSX.Element;
@@ -11,7 +11,8 @@ const InputArea_1 = require("./InputArea");
11
11
  const TypingIndicator_1 = require("./TypingIndicator");
12
12
  const ToolStatusPill_1 = require("./ToolStatusPill");
13
13
  const Qafka_styles_1 = require("./Qafka.styles");
14
- function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, toolStatus, sdkReady, sdkError, theme, handleSend, handleActionClick, handleExternalSuggestionPress, flatListRef, placeholder, maxMessageLength, showTimestamps, greetingMessage, apiGreeting, isInitialMessageEnabled, initialMessageText, componentRegistry, onBack, onClose, BackComponent, CloseComponent, NavigationButtonComponent, cardHostCallbacks, cardLifecycle, }) {
14
+ const projectUnavailable_1 = require("../services/projectUnavailable");
15
+ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, toolStatus, sdkReady, sdkError, unavailable, locale, theme, handleSend, handleActionClick, handleExternalSuggestionPress, flatListRef, placeholder, maxMessageLength, showTimestamps, greetingMessage, apiGreeting, isInitialMessageEnabled, initialMessageText, componentRegistry, onBack, onClose, BackComponent, CloseComponent, NavigationButtonComponent, cardHostCallbacks, cardLifecycle, }) {
15
16
  const styles = (0, Qafka_styles_1.getWidgetStyles)(theme);
16
17
  const renderMessage = ({ item }) => (<MessageBubble_1.MessageBubble message={item.text || item.content} role={item.role === 'system' ? 'assistant' : item.role} timestamp={new Date(item.timestamp)} theme={theme} showTimestamp={showTimestamps} actions={item.actions} onActionPress={handleActionClick} componentRegistry={componentRegistry} toolDefinition={item.toolResponse?.tool} toolResultData={item.toolResponse?.data} NavigationButtonComponent={NavigationButtonComponent} actionResults={item.actionResults} completedSteps={item.completedSteps} externalSuggestions={item.externalSuggestions} onExternalPress={handleExternalSuggestionPress} card={item.card} cardHostCallbacks={cardHostCallbacks} cardLifecycle={cardLifecycle}/>);
17
18
  const renderEmpty = () => {
@@ -54,6 +55,15 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
54
55
  </react_native_1.View>
55
56
  </react_native_1.View>)}
56
57
 
58
+ {unavailable ? (
59
+ /* Project unavailable (suspended): replace the whole chat surface —
60
+ messages, composer, error banner — with a neutral, centered message.
61
+ No "Something went wrong" bubble, no input, no typing indicator. */
62
+ <react_native_1.View style={[styles.emptyStateStyle, { flex: 1 }]}>
63
+ <react_native_1.Text style={styles.emptyStateTextStyle}>
64
+ {(0, projectUnavailable_1.unavailableMessage)(locale)}
65
+ </react_native_1.Text>
66
+ </react_native_1.View>) : (<>
57
67
  {/* Error banner */}
58
68
  {sdkError && (<react_native_1.View style={styles.errorStyle}>
59
69
  <react_native_1.Text style={styles.errorTextStyle}>{sdkError}</react_native_1.Text>
@@ -63,10 +73,10 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
63
73
  <react_native_1.FlatList ref={flatListRef} data={displayMessages} renderItem={renderMessage} keyExtractor={(item) => item.id} style={{ flex: 1, flexGrow: 1, flexShrink: 1 }} contentContainerStyle={styles.messageListStyle} ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" ListFooterComponent={toolStatus ? (<react_native_1.View style={{ paddingHorizontal: 8, paddingTop: 4, alignItems: 'flex-start' }}>
64
74
  <ToolStatusPill_1.ToolStatusPill message={toolStatus.message} theme={theme}/>
65
75
  </react_native_1.View>) : null} onContentSizeChange={() => {
66
- if (displayMessages.length > 0) {
67
- flatListRef.current?.scrollToEnd({ animated: true });
68
- }
69
- }}/>
76
+ if (displayMessages.length > 0) {
77
+ flatListRef.current?.scrollToEnd({ animated: true });
78
+ }
79
+ }}/>
70
80
 
71
81
  {/* Typing Indicator */}
72
82
  {isTyping && !streamingMessage && (<react_native_1.View style={styles.typingIndicatorContainer}>
@@ -74,11 +84,12 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
74
84
  </react_native_1.View>)}
75
85
 
76
86
  {/* Tool Status Pill now renders inside the list (ListFooterComponent)
77
- so it sits directly under the last message and scrolls with it. */}
87
+ so it sits directly under the last message and scrolls with it. */}
78
88
 
79
89
  {/* Input Area — hidden until SDK is ready so users can't type a message
80
- that would be rejected by an un-attested send. TypingIndicator in the
81
- empty state carries the "still preparing" signal. */}
90
+ that would be rejected by an un-attested send. TypingIndicator in the
91
+ empty state carries the "still preparing" signal. */}
82
92
  {sdkReady && (<InputArea_1.InputArea theme={theme} placeholder={placeholder} onSend={handleSend} isSending={isSending} maxLength={maxMessageLength}/>)}
93
+ </>)}
83
94
  </>);
84
95
  }
@@ -87,10 +87,10 @@ const { width: SCREEN_WIDTH } = react_native_1.Dimensions.get('window');
87
87
  * />
88
88
  * ```
89
89
  */
90
- exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
90
+ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
91
91
  // === ALL HOOKS FIRST (Rules of Hooks compliance) ===
92
92
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
93
- const { sdkReady, error: sdkError, resolvedApiKey, resolvedApiUrl } = (0, useSDK_1.useSDK)({
93
+ const { sdkReady, unavailable, error: sdkError, resolvedApiKey, resolvedApiUrl } = (0, useSDK_1.useSDK)({
94
94
  apiUrl: apiUrlProp,
95
95
  devConfig,
96
96
  subProjectId,
@@ -98,6 +98,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
98
98
  locale,
99
99
  onReady,
100
100
  onError,
101
+ onUnavailable,
101
102
  });
102
103
  const { theme, isLoadingTheme, greeting: apiGreeting, initialMessage: initialMessageText, isInitialMessageEnabled, voiceEnabled, } = (0, useProjectTheme_1.useProjectTheme)({
103
104
  sdkReady,
@@ -432,6 +433,8 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
432
433
  toolStatus,
433
434
  sdkReady,
434
435
  sdkError,
436
+ unavailable,
437
+ locale,
435
438
  theme,
436
439
  handleSend,
437
440
  handleActionClick,
@@ -283,14 +283,14 @@ export interface QafkaProps {
283
283
  /**
284
284
  * BCP 47 locale (e.g. "tr", "en", "tr-TR").
285
285
  *
286
- * Drives UI strings (placeholder, buttons, dashboard-supplied multi-locale
287
- * greeting) and is forwarded to the backend as part of `sdkContext.locale`.
288
- * Pair with the server-side language priority rule: when set, the AI
289
- * answers in this locale unless project instructions explicitly demand
290
- * another language.
286
+ * The language your app declares the user speaks. Forwarded to the backend
287
+ * as `sdkContext.locale`: when set, the assistant answers in this language
288
+ * unless the project's own instructions demand another one. It does not
289
+ * change the SDK's built-in UI strings.
291
290
  *
292
- * Leave undefined to fall back to whatever language the project
293
- * instructions are written in.
291
+ * Explicit only — the SDK never reads the device language for this. Leave
292
+ * undefined when your app has no language choice of its own, and the
293
+ * project's configured language decides.
294
294
  *
295
295
  * @example "tr"
296
296
  * @example "en-US"
@@ -364,16 +364,29 @@ export interface QafkaProps {
364
364
  */
365
365
  endUserData?: Record<string, unknown>;
366
366
  /**
367
- * Context object for Tool Registry.
368
- * Provides runtime context to match and execute tools.
369
- * @example { userId: '456', selectedItemId: '123' }
370
- */
367
+ * Runtime facts the assistant may use to personalize answers: the signed-in
368
+ * user's first name, their selected store or branch, account state, an
369
+ * active promotion's progress — whatever the AI should know about THIS user
370
+ * right now.
371
+ *
372
+ * Every key is visible to the AI (flattened into the prompt as `key: value`
373
+ * lines) and available to tool parameters with `source: 'context'`. Because
374
+ * these keys DO reach the prompt, identity data the AI should not see
375
+ * belongs in `endUserData` instead — that lane is never sent to the LLM.
376
+ *
377
+ * When a value has no meaningful state, send an explicit "empty" shape
378
+ * rather than omitting the key — an absent key gives the AI nothing to
379
+ * reason about, so it answers "I don't know".
380
+ *
381
+ * @example { firstName: 'Ada', selectedStoreId: '123', activeSession: { active: false } }
382
+ */
371
383
  context?: Record<string, any>;
372
384
  /**
373
- * Context description for the AI.
374
- * Helps the AI understand what the context keys mean.
375
- * @example "User is on the settings screen"
376
- */
385
+ * One human-readable sentence describing the screen the user is currently
386
+ * on. Kept separate from `context`: it describes the user's LOCATION, not a
387
+ * fact about them, and the prompt places it above the context lines.
388
+ * @example "User is on the settings screen"
389
+ */
377
390
  contextDescription?: string;
378
391
  /**
379
392
  * Custom component registry for Tool Registry responses.
@@ -422,6 +435,14 @@ export interface QafkaProps {
422
435
  */
423
436
  onError?: (error: Error) => void;
424
437
  /**
438
+ * Callback when the project becomes unavailable (suspended). The chat
439
+ * screen stays mounted and shows a neutral message instead of the normal
440
+ * chat surface; use this to hide your own entry point (launcher button,
441
+ * nav item, ...) if you don't want users opening it in this state. Not
442
+ * treated as an error — `onError` is not also called for this transition.
443
+ */
444
+ onUnavailable?: () => void;
445
+ /**
425
446
  * Callback when navigation is suggested (always triggered)
426
447
  */
427
448
  onNavigationSuggest?: (suggestion: NavigationSuggestion) => void;
@@ -534,6 +534,14 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
534
534
  setIsSending(false);
535
535
  setToolStatus(null);
536
536
  finalChunkBufferRef.current = '';
537
+ // Project suspended mid-session: flip the SDK to 'unavailable' instead of
538
+ // treating this as a normal error. QafkaSDK.markUnavailable() notifies the
539
+ // same onStatusChange listener useSDK subscribes to, so `onUnavailable`
540
+ // fires there — no onError call and no error bubble here.
541
+ if (err?.code === 'PROJECT_UNAVAILABLE') {
542
+ QafkaSDK_1.QafkaSDK.getInstance().markUnavailable();
543
+ return;
544
+ }
537
545
  const errorMessage = err.message || 'Unknown error';
538
546
  onError?.(err instanceof Error ? err : new Error(errorMessage));
539
547
  // Default fallback bubble. Apps that want a localized or custom error
@@ -10,10 +10,18 @@ export interface UseSDKOptions {
10
10
  locale?: string;
11
11
  onReady?: () => void;
12
12
  onError?: (error: Error) => void;
13
+ /**
14
+ * Fired when the project is unavailable (suspended). Mutually exclusive
15
+ * with `onError`/`onReady` for this transition — the SDK treats
16
+ * unavailability as an expected, recoverable state rather than a failure.
17
+ */
18
+ onUnavailable?: () => void;
13
19
  devConfig?: unknown;
14
20
  }
15
21
  export interface UseSDKResult {
16
22
  sdkReady: boolean;
23
+ /** True once the SDK has determined the project is unavailable (suspended). */
24
+ unavailable: boolean;
17
25
  error: string | null;
18
26
  /**
19
27
  * Dev-only: the resolved developmentKey from the consumer dev config — the
@@ -31,4 +39,4 @@ export interface UseSDKResult {
31
39
  /**
32
40
  * Custom hook for managing SDK initialization and lifecycle
33
41
  */
34
- export declare const useSDK: ({ apiUrl, subProjectId, projectId, locale, onReady, onError, devConfig, }: UseSDKOptions) => UseSDKResult;
42
+ export declare const useSDK: ({ apiUrl, subProjectId, projectId, locale, onReady, onError, onUnavailable, devConfig, }: UseSDKOptions) => UseSDKResult;
@@ -9,15 +9,18 @@ const resolve_runtime_config_source_1 = require("../resolve-runtime-config-sourc
9
9
  /**
10
10
  * Custom hook for managing SDK initialization and lifecycle
11
11
  */
12
- const useSDK = ({ apiUrl, subProjectId, projectId, locale, onReady, onError, devConfig, }) => {
12
+ const useSDK = ({ apiUrl, subProjectId, projectId, locale, onReady, onError, onUnavailable, devConfig, }) => {
13
13
  const [sdkReady, setSdkReady] = (0, react_1.useState)(false);
14
+ const [unavailable, setUnavailable] = (0, react_1.useState)(false);
14
15
  const [error, setError] = (0, react_1.useState)(null);
15
16
  const [resolvedApiKey, setResolvedApiKey] = (0, react_1.useState)(null);
16
17
  const [resolvedApiUrl, setResolvedApiUrl] = (0, react_1.useState)(apiUrl);
17
18
  const onReadyRef = (0, react_1.useRef)(onReady);
18
19
  const onErrorRef = (0, react_1.useRef)(onError);
20
+ const onUnavailableRef = (0, react_1.useRef)(onUnavailable);
19
21
  onReadyRef.current = onReady;
20
22
  onErrorRef.current = onError;
23
+ onUnavailableRef.current = onUnavailable;
21
24
  const initializeSDK = async () => {
22
25
  if (!projectId) {
23
26
  const err = new Error('Qafka: `projectId` prop is required.');
@@ -54,9 +57,17 @@ const useSDK = ({ apiUrl, subProjectId, projectId, locale, onReady, onError, dev
54
57
  locale,
55
58
  onStatusChange: (status) => {
56
59
  if (status === 'ready') {
60
+ setUnavailable(false);
57
61
  setSdkReady(true);
58
62
  onReadyRef.current?.();
59
63
  }
64
+ else if (status === 'unavailable') {
65
+ // Unavailability is an expected, recoverable state — not a
66
+ // failure — so no onError/sdkError here, only onUnavailable.
67
+ setSdkReady(false);
68
+ setUnavailable(true);
69
+ onUnavailableRef.current?.();
70
+ }
60
71
  else if (status === 'error') {
61
72
  setError('Failed to initialize SDK');
62
73
  onErrorRef.current?.(new Error('SDK initialization failed'));
@@ -82,10 +93,12 @@ const useSDK = ({ apiUrl, subProjectId, projectId, locale, onReady, onError, dev
82
93
  (0, react_1.useEffect)(() => {
83
94
  const sdk = QafkaSDK_1.QafkaSDK.getInstance();
84
95
  setSdkReady(false);
96
+ setUnavailable(false);
85
97
  setError(null);
86
98
  initializeSDK();
87
99
  return () => {
88
100
  setSdkReady(false);
101
+ setUnavailable(false);
89
102
  sdk.destroy();
90
103
  };
91
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -99,6 +112,7 @@ const useSDK = ({ apiUrl, subProjectId, projectId, locale, onReady, onError, dev
99
112
  }, [locale, sdkReady]);
100
113
  return {
101
114
  sdkReady,
115
+ unavailable,
102
116
  error,
103
117
  resolvedApiKey,
104
118
  resolvedApiUrl,
@@ -103,7 +103,14 @@ export declare class BackendService {
103
103
  */
104
104
  deleteConversation(sessionId: string): Promise<void>;
105
105
  /**
106
- * Get project theme
106
+ * Get project theme.
107
+ *
108
+ * Sends the explicit locale, when the host set one, so the server can answer
109
+ * with its translation of the project's greeting / initial message instead of
110
+ * the language they were authored in. Same source as `sdkContext.locale`: the
111
+ * `<Qafka locale>` prop only, never a device language. Unset ⇒ no param ⇒ the
112
+ * request is identical to the one earlier SDK versions send, and the server
113
+ * reads a missing param as "no signal".
107
114
  */
108
115
  getTheme(): Promise<any>;
109
116
  /**
@@ -4,6 +4,7 @@ exports.BackendService = void 0;
4
4
  const react_native_1 = require("react-native");
5
5
  const package_json_1 = require("../../package.json");
6
6
  const QafkaAttestation_1 = require("../native/QafkaAttestation");
7
+ const projectUnavailable_1 = require("./projectUnavailable");
7
8
  /**
8
9
  * Backend API Service using native fetch
9
10
  */
@@ -25,8 +26,10 @@ class BackendService {
25
26
  attestationManager = null;
26
27
  refreshing = null;
27
28
  // locale is only added to sdkContext when explicitly set by the
28
- // developer via <Qafka locale="..." />. Drives both UI strings and the
29
- // user-message context prefix on the server.
29
+ // developer via <Qafka locale="..." />. There is no device/system-language
30
+ // fallback on purpose: the server reads this field as the language the USER
31
+ // writes in, and a device default would state that for every host app that
32
+ // never made the choice. The SDK's own UI strings are unaffected by it.
30
33
  locale = null;
31
34
  // Tool Data Channel
32
35
  onToolDataRequested = null;
@@ -273,6 +276,12 @@ class BackendService {
273
276
  const errorCode = errorData.error || 'UNKNOWN_ERROR';
274
277
  const errorMessage = errorData.message || response.statusText;
275
278
  const hint = errorData.hint || '';
279
+ // Project suspended — a distinct signal from the other 403 branches
280
+ // below, callers across the SDK branch on `ProjectUnavailableError` /
281
+ // `code === 'PROJECT_UNAVAILABLE'` rather than parsing message strings.
282
+ if ((0, projectUnavailable_1.isProjectUnavailable)(response.status, errorData)) {
283
+ throw new projectUnavailable_1.ProjectUnavailableError(errorMessage);
284
+ }
276
285
  // 401: Unauthorized (Invalid/Missing API Key)
277
286
  if (response.status === 401) {
278
287
  if (errorCode === 'MISSING_API_KEY') {
@@ -399,18 +408,35 @@ class BackendService {
399
408
  }
400
409
  }
401
410
  /**
402
- * Get project theme
411
+ * Get project theme.
412
+ *
413
+ * Sends the explicit locale, when the host set one, so the server can answer
414
+ * with its translation of the project's greeting / initial message instead of
415
+ * the language they were authored in. Same source as `sdkContext.locale`: the
416
+ * `<Qafka locale>` prop only, never a device language. Unset ⇒ no param ⇒ the
417
+ * request is identical to the one earlier SDK versions send, and the server
418
+ * reads a missing param as "no signal".
403
419
  */
404
420
  async getTheme() {
405
421
  try {
406
422
  const cleanApiUrl = this.baseURL.replace(/\/api\/v1\/?$/, '');
407
- const queryParams = this.subProjectId
408
- ? `?subProjectId=${encodeURIComponent(this.subProjectId)}`
409
- : '';
423
+ const query = [];
424
+ if (this.subProjectId) {
425
+ query.push(`subProjectId=${encodeURIComponent(this.subProjectId)}`);
426
+ }
427
+ if (this.locale) {
428
+ query.push(`locale=${encodeURIComponent(this.locale)}`);
429
+ }
430
+ const queryParams = query.length > 0 ? `?${query.join('&')}` : '';
410
431
  const response = await this.fetchWithTimeout(`${cleanApiUrl}/api/v1/public/projects/chat-theme${queryParams}`, { method: 'GET' });
411
432
  return this.handleResponse(response);
412
433
  }
413
434
  catch (error) {
435
+ // Preserve ProjectUnavailableError as-is — callers (QafkaSDK.initialize)
436
+ // branch on `instanceof`, so re-wrapping here would lose that signal.
437
+ if (error instanceof projectUnavailable_1.ProjectUnavailableError) {
438
+ throw error;
439
+ }
414
440
  throw new Error(error.message || 'Failed to fetch theme');
415
441
  }
416
442
  }
@@ -645,13 +671,21 @@ class BackendService {
645
671
  });
646
672
  }
647
673
  else {
648
- let serverMessage;
674
+ let parsedBody;
649
675
  try {
650
- serverMessage = JSON.parse(xhr.responseText)?.message;
676
+ parsedBody = JSON.parse(xhr.responseText);
651
677
  }
652
678
  catch {
653
679
  // non-JSON error body; fall through to a status-based message
654
680
  }
681
+ const serverMessage = parsedBody?.message;
682
+ if ((0, projectUnavailable_1.isProjectUnavailable)(xhr.status, parsedBody)) {
683
+ const unavailableErr = new Error(serverMessage || 'The assistant is currently unavailable.');
684
+ unavailableErr.code = 'PROJECT_UNAVAILABLE';
685
+ unavailableErr.status = xhr.status;
686
+ onError(unavailableErr);
687
+ return;
688
+ }
655
689
  // In dev, a 401 with a test key almost always means the rotated dev
656
690
  // key is stale. Surface an actionable hint instead of a bare HTTP error.
657
691
  if (__DEV__ && xhr.status === 401 && this.apiKey?.startsWith('qafka_test_')) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared helpers for the "project unavailable" state: the backend answers
3
+ * with HTTP 403 `{ error: 'project_unavailable' }` when the project (or its
4
+ * parent) is suspended. The chat screen stays mounted but shows a neutral
5
+ * message instead of the normal chat surface or an error bubble.
6
+ */
7
+ /**
8
+ * True when a response is the specific "project unavailable" signal, as
9
+ * opposed to any other 403 (bundle id, platform, permission, ...).
10
+ */
11
+ export declare function isProjectUnavailable(status: number, body: unknown): boolean;
12
+ /**
13
+ * Thrown by the transport layer when a request fails because the project is
14
+ * unavailable. Callers branch on `code`, not `instanceof`, where they only
15
+ * have a plain error (e.g. across the streaming/XHR boundary).
16
+ */
17
+ export declare class ProjectUnavailableError extends Error {
18
+ code: string;
19
+ constructor(message?: string);
20
+ }
21
+ /**
22
+ * Neutral, locale-aware copy shown in place of the chat surface when the
23
+ * project is unavailable. Deliberately generic — no mention of payment,
24
+ * plans, or Qafka — so it reads the same regardless of the underlying cause.
25
+ */
26
+ export declare function unavailableMessage(locale?: string): string;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * Shared helpers for the "project unavailable" state: the backend answers
4
+ * with HTTP 403 `{ error: 'project_unavailable' }` when the project (or its
5
+ * parent) is suspended. The chat screen stays mounted but shows a neutral
6
+ * message instead of the normal chat surface or an error bubble.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.ProjectUnavailableError = void 0;
10
+ exports.isProjectUnavailable = isProjectUnavailable;
11
+ exports.unavailableMessage = unavailableMessage;
12
+ /**
13
+ * True when a response is the specific "project unavailable" signal, as
14
+ * opposed to any other 403 (bundle id, platform, permission, ...).
15
+ */
16
+ function isProjectUnavailable(status, body) {
17
+ if (status !== 403)
18
+ return false;
19
+ if (!body || typeof body !== 'object')
20
+ return false;
21
+ return body.error === 'project_unavailable';
22
+ }
23
+ /**
24
+ * Thrown by the transport layer when a request fails because the project is
25
+ * unavailable. Callers branch on `code`, not `instanceof`, where they only
26
+ * have a plain error (e.g. across the streaming/XHR boundary).
27
+ */
28
+ class ProjectUnavailableError extends Error {
29
+ code = 'PROJECT_UNAVAILABLE';
30
+ constructor(message = 'The assistant is currently unavailable.') {
31
+ super(message);
32
+ this.name = 'ProjectUnavailableError';
33
+ Object.setPrototypeOf(this, ProjectUnavailableError.prototype);
34
+ }
35
+ }
36
+ exports.ProjectUnavailableError = ProjectUnavailableError;
37
+ /**
38
+ * Neutral, locale-aware copy shown in place of the chat surface when the
39
+ * project is unavailable. Deliberately generic — no mention of payment,
40
+ * plans, or Qafka — so it reads the same regardless of the underlying cause.
41
+ */
42
+ function unavailableMessage(locale) {
43
+ return locale?.toLowerCase().startsWith('tr')
44
+ ? 'Asistan şu anda hizmet verememektedir.'
45
+ : 'The assistant is currently unavailable.';
46
+ }
@@ -23,9 +23,10 @@ export interface SDKConfig {
23
23
  /** Request timeout in ms. */
24
24
  timeout?: number;
25
25
  /**
26
- * BCP 47 locale (e.g. "tr", "en", "tr-TR"). When set, the SDK
27
- * forwards it as `sdkContext.locale` on every chat request and uses it
28
- * for UI strings.
26
+ * BCP 47 locale (e.g. "tr", "en", "tr-TR"). When set, the SDK forwards it
27
+ * as `sdkContext.locale` on every chat request — the language the assistant
28
+ * answers in. Explicit only: never derived from the device language, and it
29
+ * does not change the SDK's own UI strings.
29
30
  */
30
31
  locale?: string;
31
32
  onStatusChange?: (status: SDKStatusType) => void;
@@ -35,4 +36,4 @@ export interface SDKConfig {
35
36
  /**
36
37
  * SDK status type
37
38
  */
38
- export type SDKStatusType = 'uninitialized' | 'initializing' | 'ready' | 'error';
39
+ export type SDKStatusType = 'uninitialized' | 'initializing' | 'ready' | 'unavailable' | 'error';
@@ -278,11 +278,26 @@ to handle in code. It shows a small set of key/value chips (or routes text to th
278
278
  You may optionally restyle the chips via `voiceComponents.dataChipList` / `voiceComponents.dataChip`.
279
279
 
280
280
  ### Useful context props
281
- - **`context`** + **`contextDescription`** — a runtime data bag passed to the AI, with a
282
- description of what its keys mean. (Tool parameters with `source: 'context'` read from here.)
283
- - **`endUserData`** — non-LLM metadata about the user (≤8KB).
281
+ - **`context`** — runtime facts the AI may use to personalize answers (first name, selected
282
+ store, account state). Every key reaches the AI prompt; tool parameters with
283
+ `source: 'context'` also read from here. For a value with no meaningful state, send an
284
+ explicit "empty" shape rather than omitting the key.
285
+ - **`contextDescription`** — one sentence describing the screen the user is currently on
286
+ (their location, not a fact about them; shown to the AI above the context lines).
287
+ - **`endUserData`** — non-LLM metadata about the user (≤8KB). Never reaches the AI prompt —
288
+ the inverse of `context`.
284
289
  - **`subProjectId`** — multi-tenant routing.
285
290
  - **`locale`** — BCP-47 locale.
291
+ - **`onUnavailable`** — fires when the project becomes unavailable (suspended). The chat
292
+ screen stays mounted and shows a neutral message in place of the normal chat surface; not
293
+ treated as an error (`onError` is not also called). Use it to hide your own entry point —
294
+ e.g. a launcher button or nav item — so users can't open a chat screen with nothing to say:
295
+ ```tsx
296
+ const [chatAvailable, setChatAvailable] = useState(true);
297
+
298
+ <Qafka onUnavailable={() => setChatAvailable(false)} />
299
+ {chatAvailable && <LauncherButton onPress={openChat} />}
300
+ ```
286
301
 
287
302
  ---
288
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Drop-in AI assistant for React Native: chat, voice, and tool execution with screen-aware navigation.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",