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; +}