diff --git a/.changeset/fresh-towns-marry.md b/.changeset/fresh-towns-marry.md
new file mode 100644
index 00000000..959b34ba
--- /dev/null
+++ b/.changeset/fresh-towns-marry.md
@@ -0,0 +1,7 @@
+---
+'@youversion/platform-react-hooks': patch
+'@youversion/platform-core': patch
+'@youversion/platform-react-ui': patch
+---
+
+fix(ui): show specific Bible passage error messages
diff --git a/packages/ui/src/components/bible-card.stories.tsx b/packages/ui/src/components/bible-card.stories.tsx
index 91708e3c..cef2605e 100644
--- a/packages/ui/src/components/bible-card.stories.tsx
+++ b/packages/ui/src/components/bible-card.stories.tsx
@@ -163,7 +163,7 @@ export const Error: Story = {
await waitFor(async () => {
await expect(canvas.getByRole('heading', { level: 2, name: /error/i })).toBeInTheDocument();
const errorMessages = canvas.getAllByText(
- 'Your previously selected Bible verse is unavailable.',
+ 'The Bible service is having trouble right now. Please try again in a moment.',
);
await expect(errorMessages.length).toBeGreaterThan(0);
});
diff --git a/packages/ui/src/components/verse.test.tsx b/packages/ui/src/components/verse.test.tsx
index 2b36238e..e4900314 100644
--- a/packages/ui/src/components/verse.test.tsx
+++ b/packages/ui/src/components/verse.test.tsx
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Verse, BibleTextView, type BibleTextViewPassageState } from './verse';
@@ -694,3 +694,186 @@ describe('BibleTextView - Refetch loading behavior', () => {
});
});
});
+
+describe('BibleTextView - Error messaging', () => {
+ const originalNavigator = globalThis.navigator;
+
+ function createError(message: string, status?: number): Error {
+ return Object.assign(new Error(message), status === undefined ? {} : { status });
+ }
+
+ afterEach(() => {
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: originalNavigator,
+ });
+ });
+
+ it('should show a passage-specific message for 404 errors', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ "This passage isn't available in the selected Bible version.",
+ );
+ });
+ });
+
+ it('should show an app key message for 401 errors', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ "This Bible content couldn't be loaded because the app key is missing or invalid.",
+ );
+ });
+ });
+
+ it('should show a forbidden message for 403 errors', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ "This app isn't allowed to access this Bible content.",
+ );
+ });
+ });
+
+ it('should show an offline message when navigator reports offline', async () => {
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: {
+ ...originalNavigator,
+ onLine: false,
+ },
+ });
+
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ "The Bible server couldn't be reached. Check your connection and try again.",
+ );
+ });
+ });
+
+ it('should show an unreachable-server message for request timeouts', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ "The Bible server couldn't be reached. Check your connection and try again.",
+ );
+ });
+ });
+
+ it('should show a rate-limit message for 429 errors', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ 'The Bible service is receiving too many requests right now. Please wait a moment and try again.',
+ );
+ });
+ });
+
+ it('should show a service message for 5xx errors', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ 'The Bible service is having trouble right now. Please try again in a moment.',
+ );
+ });
+ });
+
+ it('should prioritize 5xx errors over "not found" text in the message', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole('alert')).toHaveTextContent(
+ 'The Bible service is having trouble right now. Please try again in a moment.',
+ );
+ });
+ });
+});
diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx
index 77dcff6e..93a3186d 100644
--- a/packages/ui/src/components/verse.tsx
+++ b/packages/ui/src/components/verse.tsx
@@ -15,6 +15,7 @@ import { ExclamationCircle } from '@/components/icons/exclamation-circle';
import { Footnote } from '@/components/icons/footnote';
import { LoaderIcon } from '@/components/icons/loader';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { getBibleTextErrorMessage } from '@/lib/bible-text-error';
import { cn } from '@/lib/utils';
import { type FontFamily } from '@/lib/verse-html-utils';
import { transformBibleHtml } from '@youversion/platform-core/browser';
@@ -142,13 +143,11 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({
);
});
-const VERSE_UNAVAILABLE_MESSAGE = 'Your previously selected Bible verse is unavailable.';
-
/**
* Displays a verse-unavailable error message with a circular exclamation
* icon and descriptive text.
*/
-function VerseUnavailableMessage(): React.ReactElement {
+function VerseUnavailableMessage({ message }: { message: string }): React.ReactElement {
return (
-
- {VERSE_UNAVAILABLE_MESSAGE}
-
+
{message}
);
}
@@ -470,7 +467,7 @@ export const BibleTextView = forwardRef(
if (currentError) {
return (
-
+
);
}
diff --git a/packages/ui/src/lib/bible-text-error.ts b/packages/ui/src/lib/bible-text-error.ts
new file mode 100644
index 00000000..27688c52
--- /dev/null
+++ b/packages/ui/src/lib/bible-text-error.ts
@@ -0,0 +1,60 @@
+type BibleTextError = Error & {
+ status?: number;
+};
+
+const PASSAGE_NOT_FOUND_MESSAGE = "This passage isn't available in the selected Bible version.";
+const INVALID_APP_KEY_MESSAGE =
+ "This Bible content couldn't be loaded because the app key is missing or invalid.";
+const FORBIDDEN_MESSAGE = "This app isn't allowed to access this Bible content.";
+const UNREACHABLE_SERVER_MESSAGE =
+ "The Bible server couldn't be reached. Check your connection and try again.";
+const RATE_LIMITED_MESSAGE =
+ 'The Bible service is receiving too many requests right now. Please wait a moment and try again.';
+const SERVER_ERROR_MESSAGE =
+ 'The Bible service is having trouble right now. Please try again in a moment.';
+const GENERIC_ERROR_MESSAGE = "We couldn't load this Bible passage. Please try again.";
+
+function isUnreachableServerError(message: string): boolean {
+ return (
+ message.includes('failed to fetch') ||
+ message.includes('networkerror') ||
+ message.includes('network request failed') ||
+ message.includes('load failed') ||
+ message.includes('request timeout')
+ );
+}
+
+export function getBibleTextErrorMessage(error: BibleTextError): string {
+ const status = error.status;
+ const message = error.message.toLowerCase();
+
+ if (status === 401) {
+ return INVALID_APP_KEY_MESSAGE;
+ }
+
+ if (status === 403) {
+ return FORBIDDEN_MESSAGE;
+ }
+
+ if (status === 429) {
+ return RATE_LIMITED_MESSAGE;
+ }
+
+ if (status !== undefined && status >= 500) {
+ return SERVER_ERROR_MESSAGE;
+ }
+
+ if (status === 404 || message.includes('not found')) {
+ return PASSAGE_NOT_FOUND_MESSAGE;
+ }
+
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+ return UNREACHABLE_SERVER_MESSAGE;
+ }
+
+ if (isUnreachableServerError(message)) {
+ return UNREACHABLE_SERVER_MESSAGE;
+ }
+
+ return GENERIC_ERROR_MESSAGE;
+}