diff --git a/.changeset/bible-version-picker-figma-updates.md b/.changeset/bible-version-picker-figma-updates.md new file mode 100644 index 00000000..b5b1bdba --- /dev/null +++ b/.changeset/bible-version-picker-figma-updates.md @@ -0,0 +1,11 @@ +--- +"@youversion/platform-core": minor +"@youversion/platform-react-hooks": minor +"@youversion/platform-react-ui": minor +--- + +Update the Bible Version picker to match the latest Reader SDK Figma design, adding publisher names and refreshing the abbreviation tile. + +- `@youversion/platform-core`: New `OrganizationsClient` with `getOrganization(organizationId)` for fetching an organization by its UUID (`GET /v1/organizations/{id}`), validated against the existing `OrganizationSchema`. The default design tokens now use the YouVersion brand fonts — `--yv-font-sans` is `'Aktiv Grotesk App'` and `--yv-font-serif` is `'Untitled Serif'`, with Inter / Source Serif 4 retained as graceful fallbacks. This applies SDK-wide (all components, including the Bible reader's serif text). +- `@youversion/platform-react-hooks`: New `useOrganization(organizationId)` hook (plus `useOrganizationsClient`) following the standard `useApiData` pattern. Fetching is skipped when the id is empty. Also adds `useOrganizations(organizationIds)`, which resolves many organizations at once, deduplicated by id, so a list of versions sharing publishers only fetches each organization once. +- `@youversion/platform-react-ui`: The `BibleVersionPicker` now uses the YouVersion brand fonts to match Figma — labels, headings, version titles, and the publisher name render in Aktiv Grotesk App (`--font-aktiv`), and the abbreviation tile renders in Untitled Serif Bold (new `--font-untitled-serif` token, CDN `@font-face`, falling back to Source Serif 4). `BibleVersionPicker` now renders the publisher name above the version title for versions that have an `organization_id` (rows without an associated organization render the title only), and recently used versions persist `organization_id` so they display the publisher too. Publisher names are resolved once at the list level via `useOrganizations` instead of per row, avoiding N+1 requests when many versions share a publisher. The `VersionAbbreviationIcon` tile now renders as a 64px square with a 6px radius, warm-neutral (`secondary`) fill, themed border, and serif typography using the foreground text color; recent-version and all-version rows share the same tile styling, and long or trailing-digit abbreviations (e.g. `NASB1995` → `NASB` / `1995`) stay readable without overflowing. diff --git a/packages/core/src/__tests__/MockOrganizations.ts b/packages/core/src/__tests__/MockOrganizations.ts new file mode 100644 index 00000000..ae85e9ef --- /dev/null +++ b/packages/core/src/__tests__/MockOrganizations.ts @@ -0,0 +1,17 @@ +import type { Organization } from '../types'; + +export const mockLockmanOrganization: Organization = { + id: '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', + name: 'The Lockman Foundation', + primary_language: 'en', + website_url: 'https://www.lockman.org', +}; + +export const mockBiblicaOrganization: Organization = { + id: '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff', + name: 'Biblica', + primary_language: 'en', + website_url: 'https://www.biblica.com', +}; + +export const mockOrganizations: Organization[] = [mockLockmanOrganization, mockBiblicaOrganization]; diff --git a/packages/core/src/__tests__/handlers.ts b/packages/core/src/__tests__/handlers.ts index bee770e7..8a42d59c 100644 --- a/packages/core/src/__tests__/handlers.ts +++ b/packages/core/src/__tests__/handlers.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw'; import type { Collection, Highlight, Language } from '../types'; import { mockLanguages } from './MockLanguages'; import { mockVersions, mockVersionKJV } from './MockVersions'; +import { mockOrganizations } from './MockOrganizations'; import { mockBibleGenesis, mockBibleBooks } from './MockBibles'; import { mockChapterGenesis1, mockGenesisChapters } from './MockChapters'; import { mockGen1Verse1, mockGen1Verses } from './MockVerses'; @@ -22,6 +23,17 @@ if (!apiHost) { } export const handlers = [ + // Organizations endpoints + http.get(`https://${apiHost}/v1/organizations/:organizationId`, ({ params }) => { + const { organizationId } = params; + const organization = mockOrganizations.find((org) => org.id === organizationId); + + if (!organization) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json(organization); + }), // Languages endpoints http.get(`https://${apiHost}/v1/languages/:languageId`, ({ params }) => { const { languageId } = params; diff --git a/packages/core/src/__tests__/organizations.test.ts b/packages/core/src/__tests__/organizations.test.ts new file mode 100644 index 00000000..f25df5d5 --- /dev/null +++ b/packages/core/src/__tests__/organizations.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ApiClient } from '../client'; +import { OrganizationsClient } from '../organizations'; +import { OrganizationSchema } from '../schemas'; + +describe('OrganizationsClient', () => { + let apiClient: ApiClient; + let organizationsClient: OrganizationsClient; + + beforeEach(() => { + apiClient = new ApiClient({ + apiHost: process.env.YVP_API_HOST || '', + appKey: process.env.YVP_APP_KEY || '', + installationId: 'test-installation', + }); + organizationsClient = new OrganizationsClient(apiClient); + }); + + describe('getOrganization', () => { + it('should fetch an organization by ID', async () => { + const organization = await organizationsClient.getOrganization( + '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', + ); + + const { success } = OrganizationSchema.safeParse(organization); + expect(success).toBe(true); + expect(organization.id).toBe('798d8fa4-f640-4155-8cfb-fa91d1d8a06c'); + expect(organization.name).toBe('The Lockman Foundation'); + }); + + it('should request the organization endpoint with the provided ID', async () => { + const getSpy = vi.spyOn(apiClient, 'get'); + + await organizationsClient.getOrganization('05a9aa40-37b6-4e34-b9f1-a443fa4b1fff'); + + expect(getSpy).toHaveBeenCalledWith('/v1/organizations/05a9aa40-37b6-4e34-b9f1-a443fa4b1fff'); + getSpy.mockRestore(); + }); + + it('should throw an error for invalid organization ID', async () => { + await expect(organizationsClient.getOrganization('not-a-uuid')).rejects.toThrow( + 'Organization ID must be a valid UUID', + ); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05374090..884b741b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { ApiClient } from './client'; export { BibleClient } from './bible'; export { LanguagesClient, type GetLanguagesOptions } from './languages'; +export { OrganizationsClient } from './organizations'; export { HighlightsClient, type GetHighlightsOptions, diff --git a/packages/core/src/organizations.ts b/packages/core/src/organizations.ts new file mode 100644 index 00000000..fd119ceb --- /dev/null +++ b/packages/core/src/organizations.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import type { ApiClient } from './client'; +import { OrganizationSchema } from './schemas'; +import type { Organization } from './types'; + +/** Client for interacting with Organization API endpoints. */ +export class OrganizationsClient { + private client: ApiClient; + + private static readonly organizationIdSchema = z + .string() + .trim() + .uuid('Organization ID must be a valid UUID'); + + /** Creates a new OrganizationsClient instance. */ + constructor(client: ApiClient) { + this.client = client; + } + + /** + * Fetches an organization by its ID. + * @param organizationId The organization UUID. + * @returns The requested Organization object. + */ + async getOrganization(organizationId: string): Promise { + const parsedOrganizationId = OrganizationsClient.organizationIdSchema.parse(organizationId); + const organization = await this.client.get( + `/v1/organizations/${parsedOrganizationId}`, + ); + + return OrganizationSchema.parse(organization); + } +} diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css index 0f0c2abb..a1fef561 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -108,8 +108,11 @@ --yv-sidebar-border: var(--yv-gray-15); --yv-sidebar-ring: var(--yv-blue-30); - --yv-font-sans: 'Inter', sans-serif; - --yv-font-serif: 'Source Serif 4', serif; + /* YouVersion brand fonts (loaded via @font-face in the UI package's global.css, + served from the prod CDN). Inter / Source Serif 4 remain as graceful fallbacks + if the brand woff2 files fail to load (e.g. a consumer's strict font-src CSP). */ + --yv-font-sans: 'Aktiv Grotesk App', 'Inter', sans-serif; + --yv-font-serif: 'Untitled Serif', 'Source Serif 4', serif; --yv-reader-font-family: var(--yv-font-serif), var(--yv-font-sans); &[data-yv-theme='dark'] { diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 04d13c20..c6b39d73 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -8,6 +8,9 @@ export * from './useVersion'; export * from './utility/useDebounce'; export * from './useVersions'; export * from './useFilteredVersions'; +export * from './useOrganization'; +export * from './useOrganizations'; +export * from './useOrganizationsClient'; export * from './context'; export * from './utility'; export * from './useBibleClient'; diff --git a/packages/hooks/src/useOrganization.test.tsx b/packages/hooks/src/useOrganization.test.tsx new file mode 100644 index 00000000..4dfa8446 --- /dev/null +++ b/packages/hooks/src/useOrganization.test.tsx @@ -0,0 +1,139 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, expect, vi, beforeEach, it } from 'vitest'; +import { useOrganization } from './useOrganization'; +import { type Organization, type OrganizationsClient } from '@youversion/platform-core'; +import { useOrganizationsClient } from './useOrganizationsClient'; +import { createYVWrapper } from './test/utils'; + +vi.mock('./useOrganizationsClient'); + +describe('useOrganization', () => { + const mockGetOrganization = vi.fn(); + + const mockOrganization: Organization = { + id: '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', + name: 'The Lockman Foundation', + primary_language: 'en', + website_url: 'https://www.lockman.org', + }; + + beforeEach(() => { + mockGetOrganization.mockResolvedValue(mockOrganization); + + const mockClient: Partial = { getOrganization: mockGetOrganization }; + vi.mocked(useOrganizationsClient).mockReturnValue(mockClient as OrganizationsClient); + }); + + describe('fetching organization', () => { + it('should fetch organization by ID', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useOrganization('798d8fa4-f640-4155-8cfb-fa91d1d8a06c'), { + wrapper, + }); + + expect(result.current.loading).toBe(true); + expect(result.current.organization).toBe(null); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect.soft(mockGetOrganization).toHaveBeenCalledWith('798d8fa4-f640-4155-8cfb-fa91d1d8a06c'); + expect.soft(result.current.organization).toEqual(mockOrganization); + }); + + it('should refetch when organizationId changes', async () => { + const wrapper = createYVWrapper(); + const { rerender } = renderHook(({ organizationId }) => useOrganization(organizationId), { + wrapper, + initialProps: { organizationId: '798d8fa4-f640-4155-8cfb-fa91d1d8a06c' }, + }); + + await waitFor(() => { + expect(mockGetOrganization).toHaveBeenCalledTimes(1); + }); + + rerender({ organizationId: '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff' }); + + await waitFor(() => { + expect(mockGetOrganization).toHaveBeenCalledTimes(2); + }); + + expect(mockGetOrganization).toHaveBeenNthCalledWith( + 2, + '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff', + ); + }); + }); + + describe('enabled option', () => { + it('should not fetch when enabled is false', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook( + () => useOrganization('798d8fa4-f640-4155-8cfb-fa91d1d8a06c', { enabled: false }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect.soft(mockGetOrganization).not.toHaveBeenCalled(); + expect.soft(result.current.organization).toBe(null); + }); + + it('should not fetch when organizationId is empty', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useOrganization(''), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect.soft(mockGetOrganization).not.toHaveBeenCalled(); + expect.soft(result.current.organization).toBe(null); + }); + }); + + describe('error handling', () => { + it('should handle fetch errors', async () => { + const wrapper = createYVWrapper(); + const error = new Error('Failed to fetch organization'); + mockGetOrganization.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useOrganization('798d8fa4-f640-4155-8cfb-fa91d1d8a06c'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect.soft(result.current.error).toEqual(error); + expect.soft(result.current.organization).toBe(null); + }); + }); + + describe('manual refetch', () => { + it('should support manual refetch', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useOrganization('798d8fa4-f640-4155-8cfb-fa91d1d8a06c'), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect.soft(mockGetOrganization).toHaveBeenCalledTimes(1); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => { + expect(mockGetOrganization).toHaveBeenCalledTimes(2); + }); + }); + }); +}); diff --git a/packages/hooks/src/useOrganization.ts b/packages/hooks/src/useOrganization.ts new file mode 100644 index 00000000..4146a6d5 --- /dev/null +++ b/packages/hooks/src/useOrganization.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useApiData, type UseApiDataOptions } from './useApiData'; +import { type Organization } from '@youversion/platform-core'; +import { useOrganizationsClient } from './useOrganizationsClient'; + +export function useOrganization( + organizationId: string, + apiOptions?: UseApiDataOptions, +): { + organization: Organization | null; + loading: boolean; + error: Error | null; + refetch: () => void; +} { + const organizationsClient = useOrganizationsClient(); + const enabled = apiOptions?.enabled !== false && organizationId.trim().length > 0; + + const { data, loading, error, refetch } = useApiData( + () => organizationsClient.getOrganization(organizationId), + [organizationsClient, organizationId], + { + enabled, + }, + ); + + return { + organization: data, + loading, + error, + refetch, + }; +} diff --git a/packages/hooks/src/useOrganizations.test.tsx b/packages/hooks/src/useOrganizations.test.tsx new file mode 100644 index 00000000..984a25ef --- /dev/null +++ b/packages/hooks/src/useOrganizations.test.tsx @@ -0,0 +1,112 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, vi, beforeEach, it } from 'vitest'; +import { useOrganizations } from './useOrganizations'; +import { type Organization, type OrganizationsClient } from '@youversion/platform-core'; +import { useOrganizationsClient } from './useOrganizationsClient'; +import { createYVWrapper } from './test/utils'; + +vi.mock('./useOrganizationsClient'); + +const ORG_A = '798d8fa4-f640-4155-8cfb-fa91d1d8a06c'; +const ORG_B = '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff'; + +function makeOrg(id: string, name: string): Organization { + return { id, name, primary_language: 'en', website_url: `https://example.com/${id}` }; +} + +describe('useOrganizations', () => { + const mockGetOrganization = vi.fn(); + + beforeEach(() => { + mockGetOrganization.mockReset(); + mockGetOrganization.mockImplementation((id: string) => + Promise.resolve(makeOrg(id, `Org ${id}`)), + ); + + const mockClient: Partial = { getOrganization: mockGetOrganization }; + vi.mocked(useOrganizationsClient).mockReturnValue(mockClient as OrganizationsClient); + }); + + it('fetches each unique id once, deduplicating', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useOrganizations([ORG_A, ORG_A, ORG_B, null, '']), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.organizations.size).toBe(2); + }); + + expect.soft(mockGetOrganization).toHaveBeenCalledTimes(2); + expect.soft(result.current.organizations.get(ORG_A)?.id).toBe(ORG_A); + expect.soft(result.current.organizations.get(ORG_B)?.id).toBe(ORG_B); + }); + + it('keeps successful results when some fetches fail', async () => { + mockGetOrganization.mockImplementation((id: string) => + id === ORG_B ? Promise.reject(new Error('boom')) : Promise.resolve(makeOrg(id, 'A')), + ); + + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useOrganizations([ORG_A, ORG_B]), { wrapper }); + + await waitFor(() => { + expect(result.current.organizations.size).toBe(1); + }); + + expect.soft(result.current.organizations.has(ORG_A)).toBe(true); + expect.soft(result.current.organizations.has(ORG_B)).toBe(false); + }); + + it('does not refetch ids already cached when the id set grows', async () => { + const wrapper = createYVWrapper(); + const { rerender, result } = renderHook(({ ids }) => useOrganizations(ids), { + wrapper, + initialProps: { ids: [ORG_A] }, + }); + + await waitFor(() => { + expect(result.current.organizations.size).toBe(1); + }); + expect(mockGetOrganization).toHaveBeenCalledTimes(1); + + rerender({ ids: [ORG_A, ORG_B] }); + + await waitFor(() => { + expect(result.current.organizations.size).toBe(2); + }); + + // Only ORG_B is fetched on the second pass; ORG_A served from cache. + expect.soft(mockGetOrganization).toHaveBeenCalledTimes(2); + expect.soft(mockGetOrganization).toHaveBeenNthCalledWith(2, ORG_B); + }); + + it('invalidates the cache and refetches when the client identity changes', async () => { + const wrapper = createYVWrapper(); + const { rerender, result } = renderHook(({ ids }) => useOrganizations(ids), { + wrapper, + initialProps: { ids: [ORG_A] }, + }); + + await waitFor(() => { + expect(result.current.organizations.size).toBe(1); + }); + expect(mockGetOrganization).toHaveBeenCalledTimes(1); + + // Swap in a new client object (same id set) — e.g. appKey/host change. + const newClient: Partial = { getOrganization: mockGetOrganization }; + vi.mocked(useOrganizationsClient).mockReturnValue(newClient as OrganizationsClient); + + rerender({ ids: [ORG_A] }); + + // Same id set, but the cache must be invalidated and ORG_A refetched. + await waitFor(() => { + expect(mockGetOrganization).toHaveBeenCalledTimes(2); + }); + expect(mockGetOrganization).toHaveBeenNthCalledWith(2, ORG_A); + + await waitFor(() => { + expect(result.current.organizations.get(ORG_A)?.id).toBe(ORG_A); + }); + }); +}); diff --git a/packages/hooks/src/useOrganizations.ts b/packages/hooks/src/useOrganizations.ts new file mode 100644 index 00000000..6507bac7 --- /dev/null +++ b/packages/hooks/src/useOrganizations.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { type Organization, type OrganizationsClient } from '@youversion/platform-core'; +import { useOrganizationsClient } from './useOrganizationsClient'; + +/** Normalizes a raw id list into a unique set of non-empty, trimmed ids. */ +function toUniqueIds(ids: (string | null | undefined)[]): string[] { + return Array.from(new Set(ids.filter((id): id is string => !!id && id.trim().length > 0))); +} + +/** + * Fetches the given organizations concurrently, tolerating individual failures. + * Returns a Map of only the successfully-resolved entries; rejected requests + * are omitted so a single failure never rejects the batch. + */ +async function fetchOrganizations( + client: OrganizationsClient, + ids: string[], +): Promise> { + const results = await Promise.allSettled(ids.map((id) => client.getOrganization(id))); + const resolved = new Map(); + ids.forEach((id, index) => { + const result = results[index]; + if (result?.status === 'fulfilled') resolved.set(id, result.value); + }); + return resolved; +} + +/** + * Resolves multiple organizations at once, deduplicating by id so a list of + * versions that share publishers only triggers one request per unique + * organization. Returns a Map keyed by organization id. + */ +export function useOrganizations(organizationIds: (string | null | undefined)[]): { + organizations: Map; +} { + const client = useOrganizationsClient(); + const [organizations, setOrganizations] = useState>(new Map()); + const cacheRef = useRef>(new Map()); + const clientRef = useRef(client); + + const uniqueIds = toUniqueIds(organizationIds); + // Stable dependency key so the effect only re-runs when the id set changes. + const idsKey = uniqueIds.slice().sort().join(','); + + useEffect(() => { + // A new client identity (e.g. appKey/host change) invalidates the cache. + // Handled here so there is no cross-effect ordering dependence. + if (clientRef.current !== client) { + clientRef.current = client; + cacheRef.current = new Map(); + setOrganizations(new Map()); + } + + const missing = uniqueIds.filter((id) => !cacheRef.current.has(id)); + if (missing.length === 0) return; + + let cancelled = false; + void fetchOrganizations(client, missing).then((resolved) => { + if (cancelled || resolved.size === 0) return; + resolved.forEach((org, id) => cacheRef.current.set(id, org)); + setOrganizations(new Map(cacheRef.current)); + }); + + return () => { + cancelled = true; + }; + }, [client, idsKey]); + + return { organizations }; +} diff --git a/packages/hooks/src/useOrganizationsClient.ts b/packages/hooks/src/useOrganizationsClient.ts new file mode 100644 index 00000000..d1224d4f --- /dev/null +++ b/packages/hooks/src/useOrganizationsClient.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useContext, useMemo } from 'react'; +import { YouVersionContext } from './context'; +import { ApiClient, OrganizationsClient } from '@youversion/platform-core'; + +export function useOrganizationsClient(): OrganizationsClient { + const context = useContext(YouVersionContext); + + return useMemo(() => { + if (!context?.appKey) { + throw new Error( + 'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.', + ); + } + + return new OrganizationsClient( + new ApiClient({ + appKey: context.appKey, + apiHost: context.apiHost, + installationId: context.installationId, + additionalHeaders: context.additionalHeaders, + }), + ); + }, [context?.apiHost, context?.appKey, context?.installationId, context?.additionalHeaders]); +} diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx index ededafa0..82934570 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -230,7 +230,7 @@ describe('BibleChapterPicker - typography (matches Figma: Aktiv Grotesk App)', ( // GEN is the default-expanded book (book="GEN"). const genesisTrigger = findAccordionTrigger(/Genesis/i); expect(genesisTrigger).toBeDefined(); - expect(genesisTrigger).toHaveClass('yv:font-aktiv', 'yv:text-base', 'yv:font-normal'); + expect(genesisTrigger).toHaveClass('yv:text-base', 'yv:font-normal'); expect(genesisTrigger).toHaveAttribute('data-state', 'open'); expect(genesisTrigger).toHaveClass('yv:data-[state=open]:font-bold'); }); @@ -240,12 +240,12 @@ describe('BibleChapterPicker - typography (matches Figma: Aktiv Grotesk App)', ( const chapterButton = screen.getByText('2').closest('button'); expect(chapterButton).not.toBeNull(); - expect(chapterButton).toHaveClass('yv:font-aktiv', 'yv:text-base', 'yv:font-bold'); + expect(chapterButton).toHaveClass('yv:text-base', 'yv:font-bold'); }); it('search input uses Aktiv 16px', () => { renderContent(); - expect(screen.getByPlaceholderText('Search')).toHaveClass('yv:font-aktiv', 'yv:text-base'); + expect(screen.getByPlaceholderText('Search')).toHaveClass('yv:text-base'); }); }); diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index c704672e..27b9de77 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -340,7 +340,7 @@ function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { id={bookItem.id} ref={(node) => registerBookElement(bookItem.id, node)} > - + {bookItem.title} @@ -367,7 +367,7 @@ function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { key={`${bookItem.id}-${chapterRef.passage_id}`} variant="secondary" size="icon" - className="yv:aspect-square yv:w-full yv:h-full yv:flex yv:items-center yv:justify-center yv:rounded-[4px] yv:font-aktiv yv:text-base yv:font-bold yv:leading-none yv:text-foreground" + className="yv:aspect-square yv:w-full yv:h-full yv:flex yv:items-center yv:justify-center yv:rounded-[4px] yv:text-base yv:font-bold yv:leading-none yv:text-foreground" onClick={() => handleChapterButtonClick(bookItem.id, chapterRef.passage_id) } @@ -398,7 +398,7 @@ function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { tabIndex={1} type="text" placeholder={t('searchPlaceholder')} - className="yv:font-aktiv yv:text-base yv:leading-normal" + className="yv:text-base yv:leading-normal" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 85420270..c99e33d5 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -1,4 +1,9 @@ -import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils'; +import { + AKTIV_FONT, + INTER_FONT, + SOURCE_SERIF_FONT, + UNTITLED_SERIF_FONT, +} from '@/lib/verse-html-utils'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { delay, http, HttpResponse } from 'msw'; import { expect, fn, screen, spyOn, userEvent, waitFor } from 'storybook/test'; @@ -49,7 +54,7 @@ const meta: Meta = { }, fontFamily: { control: 'select', - options: [SOURCE_SERIF_FONT, INTER_FONT, "'Georgia', serif", "'Nunito Sans', sans-serif"], + options: [UNTITLED_SERIF_FONT, AKTIV_FONT, INTER_FONT, SOURCE_SERIF_FONT], description: 'Font family', }, showVerseNumbers: { @@ -73,7 +78,7 @@ export const Default: Story = { args: { defaultVersionId: 111, lineSpacing: 1.7, - fontFamily: "'Inter', sans-serif", + fontFamily: AKTIV_FONT, showVerseNumbers: true, }, render: (args) => ( @@ -126,16 +131,16 @@ export const Default: Story = { await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('12'); await expect(decreaseFontButton).toBeDisabled(); - const interButton = screen.getByRole('button', { name: /inter/i }); - const sourceSerifButton = screen.getByRole('button', { name: /source serif/i }); + const aktivButton = screen.getByRole('button', { name: /aktiv/i }); + const untitledSerifButton = screen.getByRole('button', { name: /untitled serif/i }); - await userEvent.click(sourceSerifButton); + await userEvent.click(untitledSerifButton); await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe( - SOURCE_SERIF_FONT, + UNTITLED_SERIF_FONT, ); - await userEvent.click(interButton); - await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT); + await userEvent.click(aktivButton); + await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(AKTIV_FONT); }, }; @@ -147,7 +152,7 @@ export const DarkTheme: Story = { defaultVersionId: 111, fontSize: 16, lineSpacing: 1.7, - fontFamily: "'Inter', sans-serif", + fontFamily: AKTIV_FONT, showVerseNumbers: true, }, globals: { @@ -172,7 +177,7 @@ export const CustomStyling: Story = { defaultVersionId: 111, fontSize: 18, lineSpacing: 2.0, - fontFamily: "'Nunito Sans', sans-serif", + fontFamily: UNTITLED_SERIF_FONT, showVerseNumbers: false, }, render: (args) => ( @@ -202,7 +207,7 @@ export const FontSizeOutOfRange: Story = { defaultVersionId: 111, fontSize: 28, lineSpacing: 2.0, - fontFamily: "'Nunito Sans', sans-serif", + fontFamily: UNTITLED_SERIF_FONT, showVerseNumbers: false, }, render: (args) => ( @@ -505,7 +510,7 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { localStorage.clear(); // Pre-populate localStorage with saved preferences localStorage.setItem('youversion-platform:reader:font-size', '18'); - localStorage.setItem('youversion-platform:reader:font-family', SOURCE_SERIF_FONT); + localStorage.setItem('youversion-platform:reader:font-family', UNTITLED_SERIF_FONT); }, render: (args) => (
@@ -530,7 +535,7 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { )!; await expect(verseContainer.style.getPropertyValue('--yv-reader-font-size')).toBe('18px'); await expect(verseContainer.style.getPropertyValue('--yv-reader-font-family')).toBe( - SOURCE_SERIF_FONT, + UNTITLED_SERIF_FONT, ); // Open settings and verify the correct font family button is active @@ -541,11 +546,11 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); }); - const sourceSerifButton = screen.getByRole('button', { name: /source serif/i }); - await expect(sourceSerifButton).toHaveClass('yv:bg-primary'); + const untitledSerifButton = screen.getByRole('button', { name: /untitled serif/i }); + await expect(untitledSerifButton).toHaveClass('yv:bg-primary'); - const interButton = screen.getByRole('button', { name: /inter/i }); - await expect(interButton).not.toHaveClass('yv:bg-primary'); + const aktivButton = screen.getByRole('button', { name: /aktiv/i }); + await expect(aktivButton).not.toHaveClass('yv:bg-primary'); }, }; diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 6818abaa..bdbbfa3d 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -13,6 +13,7 @@ import { useFilteredVersions, useLanguage, useLanguages, + useOrganizations, useTheme, useVersion, useVersions, @@ -27,7 +28,7 @@ import { nextBibleReaderFontSizeUp, type BibleThemeSettingsSnapshot, } from './bible-reader'; -import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils'; +import { AKTIV_FONT, INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils'; class ResizeObserverMock { observe() {} @@ -44,6 +45,7 @@ vi.mock('@youversion/platform-react-hooks', async () => { useFilteredVersions: vi.fn(), useLanguage: vi.fn(), useLanguages: vi.fn(), + useOrganizations: vi.fn(), useTheme: vi.fn(), useVersion: vi.fn(), useVersions: vi.fn(), @@ -105,6 +107,7 @@ function setupDefaultMocks() { refetch: vi.fn(), }); vi.mocked(useFilteredVersions).mockReturnValue([]); + vi.mocked(useOrganizations).mockReturnValue({ organizations: new Map() }); } describe('BibleReader font helpers', () => { @@ -203,9 +206,9 @@ describe('BibleReader theme settings', () => { expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); }); - await user.click(screen.getByRole('button', { name: /inter/i })); + await user.click(screen.getByRole('button', { name: /aktiv/i })); await waitFor(() => { - expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT); + expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(AKTIV_FONT); }); }); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 1b8a49be..3c059a93 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -3,7 +3,7 @@ import i18n from '@/i18n'; import { useDelayedLoading } from '@/lib/use-delayed-loading'; import { cn } from '@/lib/utils'; -import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils'; +import { AKTIV_FONT, UNTITLED_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import type { BibleBook } from '@youversion/platform-core'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; @@ -235,7 +235,7 @@ function Root({ defaultFontSize = DEFAULT_FONT_SIZE, onFontSizeChange, fontFamily: fontFamilyProp, - defaultFontFamily = SOURCE_SERIF_FONT, + defaultFontFamily = UNTITLED_SERIF_FONT, onFontFamilyChange, lineSpacing: lineSpacingProp, defaultLineSpacing, @@ -688,42 +688,42 @@ export function BibleThemeSettingsContent({ diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 37d4eae9..5165973f 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -26,9 +26,10 @@ import { useLanguages, useLanguage, useFilteredVersions, + useOrganizations, useTheme, } from '@youversion/platform-react-hooks'; -import type { BibleVersion, Language } from '@youversion/platform-core'; +import type { BibleVersion, Language, Organization } from '@youversion/platform-core'; vi.mock('@youversion/platform-react-hooks'); @@ -42,6 +43,7 @@ const mockVersions: BibleVersion[] = [ language_tag: 'en', books: ['GEN', 'EXO'], youversion_deep_link: 'https://bible.com/versions/111', + organization_id: '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff', }, { id: 206, @@ -55,6 +57,11 @@ const mockVersions: BibleVersion[] = [ }, ]; +const mockOrganization: Organization = { + id: '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff', + name: 'Biblica', +}; + const mockLanguages: Language[] = [ { id: 'en', @@ -110,6 +117,10 @@ function setupDefaultMocks({ vi.mocked(useFilteredVersions).mockReturnValue(filteredVersions); + vi.mocked(useOrganizations).mockReturnValue({ + organizations: new Map([[mockOrganization.id, mockOrganization]]), + }); + vi.mocked(useTheme).mockReturnValue('light'); } @@ -243,6 +254,96 @@ describe('BibleVersionPicker', () => { }); }); + describe('abbreviation tile', () => { + it('renders the abbreviation tile with the Figma media styling', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + const row = await screen.findByRole('listitem', { name: /new international version/i }); + const media = row.querySelector('[data-slot="item-media"]'); + expect(media).not.toBeNull(); + // tile: 64px square, 6px radius, warm-neutral fill, themed border + expect(media!.className).toContain('yv:size-16'); + expect(media!.className).toContain('yv:rounded-md'); + expect(media!.className).toContain('yv:bg-secondary'); + expect(media!.className).toContain('yv:border-border'); + expect(media!.textContent).toContain('NIV'); + }); + + it('splits a trailing-digit abbreviation onto a second line', async () => { + setupDefaultMocks({ + versionsLoading: false, + filteredVersions: [ + { + ...mockVersions[0]!, + id: 1995, + title: 'New American Standard Bible', + localized_abbreviation: 'NASB1995', + abbreviation: 'NASB1995', + }, + ], + }); + renderPicker(); + await openPicker(); + + const row = await screen.findByRole('listitem', { name: /new american standard bible/i }); + const media = row.querySelector('[data-slot="item-media"]'); + expect(media).not.toBeNull(); + // prefix + digits render as separate lines, not the raw concatenation + const lines = Array.from(media!.querySelectorAll('div > div')).map((n) => n.textContent); + expect(lines).toContain('NASB'); + expect(lines).toContain('1995'); + }); + + it('renders the publisher name above the version title when available', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + const row = await screen.findByRole('listitem', { name: /new international version/i }); + const description = row.querySelector('[data-slot="item-description"]'); + expect(description).not.toBeNull(); + expect(description!.textContent).toBe('Biblica'); + }); + + it('omits the publisher name when the version has no organization', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + const row = await screen.findByRole('listitem', { name: /new living translation/i }); + const description = row.querySelector('[data-slot="item-description"]'); + expect(description).toBeNull(); + }); + + it('applies the same tile styling to recent-version rows', async () => { + localStorage.clear(); + localStorage.setItem( + RECENT_VERSIONS_KEY, + JSON.stringify([ + { + id: 111, + title: 'New International Version', + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + }, + ]), + ); + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + const recentList = await screen.findByTestId('recent-version-list'); + const media = recentList.querySelector('[data-slot="item-media"]'); + expect(media).not.toBeNull(); + expect(media!.className).toContain('yv:size-16'); + expect(media!.className).toContain('yv:bg-secondary'); + expect(media!.className).toContain('yv:rounded-md'); + localStorage.clear(); + }); + }); + describe('onVersionPickerPress override', () => { it('calls onVersionPickerPress with { versionId, languageId } when Trigger is clicked', async () => { const user = userEvent.setup(); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 8e0160c9..8ce007f2 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -6,6 +6,7 @@ import { useFilteredVersions, useLanguage, useLanguages, + useOrganizations, useTheme, useVersion, useVersions, @@ -38,7 +39,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; export const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; const MAX_RECENT_VERSIONS = 3; -type RecentVersion = Pick; +type RecentVersion = Pick< + BibleVersion, + 'id' | 'title' | 'localized_abbreviation' | 'abbreviation' | 'organization_id' +>; function getRecentVersions(): RecentVersion[] { if (typeof window === 'undefined') return []; @@ -56,6 +60,16 @@ function saveRecentVersions(versions: RecentVersion[]): void { localStorage.setItem(RECENT_VERSIONS_KEY, JSON.stringify(versions)); } +// Displays the publisher (organization) name for a version when available. +// The name is resolved once at the list level (see Content) and passed in, so +// rows sharing a publisher don't each fire their own request. Renders nothing +// when no name is known, so the title stays vertically centered. +function VersionPublisherName({ name }: { name?: string | null }) { + if (!name) return null; + + return {name}; +} + // Displays a version abbreviation (e.g., "NIV", "KJV2") centered within a fixed-size icon. // Dynamically scales the font size to fit the text within the container with padding. function VersionAbbreviationIcon({ text }: { text: string }) { @@ -123,7 +137,7 @@ function VersionAbbreviationIcon({ text }: { text: string }) { return (
{prefix} @@ -528,6 +542,15 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) } = useBibleVersionPickerContext(); const wasOpenRef = useRef(open ?? false); + // Resolve publisher names once for the whole list, deduped by organization id, + // instead of mounting a fetching hook per row (avoids N+1 requests). + const { organizations } = useOrganizations([ + ...filteredRecentVersions.map((version) => version.organization_id), + ...filteredVersions.map((version) => version.organization_id), + ]); + const publisherName = (organizationId?: string | null) => + organizationId ? (organizations.get(organizationId)?.name ?? null) : null; + const handleSelectVersion = (version: BibleVersion | RecentVersion) => { setVersionId(version.id); addRecentVersion({ @@ -535,6 +558,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) title: version.title, localized_abbreviation: version.localized_abbreviation, abbreviation: version.abbreviation, + organization_id: version.organization_id, }); setSearchQuery(''); onRequestClose?.(); @@ -641,11 +665,12 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) > + {version.title} @@ -680,11 +705,12 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) > + {version.title} diff --git a/packages/ui/src/i18n/locales/en.json b/packages/ui/src/i18n/locales/en.json index bb9bcb97..7ba43d63 100644 --- a/packages/ui/src/i18n/locales/en.json +++ b/packages/ui/src/i18n/locales/en.json @@ -22,6 +22,8 @@ "fontLabel": "Font", "interFontName": "Inter", "sourceSerifFontName": "Source Serif", + "aktivFontName": "Aktiv Grotesk", + "untitledSerifFontName": "Untitled Serif", "searchPlaceholder": "Search", "booksHeading": "Books", "backToBibleVersionsAriaLabel": "Back to Bible versions", diff --git a/packages/ui/src/i18n/locales/es.json b/packages/ui/src/i18n/locales/es.json index f76e58d2..f3a5c964 100644 --- a/packages/ui/src/i18n/locales/es.json +++ b/packages/ui/src/i18n/locales/es.json @@ -22,6 +22,8 @@ "fontLabel": "Fuente", "interFontName": "Inter", "sourceSerifFontName": "Source Serif", + "aktivFontName": "Aktiv Grotesk", + "untitledSerifFontName": "Untitled Serif", "searchPlaceholder": "Buscar", "booksHeading": "Libros", "backToBibleVersionsAriaLabel": "Volver a versiones de la Biblia", diff --git a/packages/ui/src/i18n/locales/fr.json b/packages/ui/src/i18n/locales/fr.json index f0430e98..a1f51570 100644 --- a/packages/ui/src/i18n/locales/fr.json +++ b/packages/ui/src/i18n/locales/fr.json @@ -22,6 +22,8 @@ "fontLabel": "Police", "interFontName": "Inter", "sourceSerifFontName": "Source Serif", + "aktivFontName": "Aktiv Grotesk", + "untitledSerifFontName": "Untitled Serif", "searchPlaceholder": "Rechercher", "booksHeading": "Livres", "backToBibleVersionsAriaLabel": "Retour aux versions de la Bible", diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts index 51839218..7c1d1c97 100644 --- a/packages/ui/src/lib/verse-html-utils.ts +++ b/packages/ui/src/lib/verse-html-utils.ts @@ -1,3 +1,13 @@ +// YouVersion brand fonts (loaded via @font-face in global.css, served from the prod CDN). +// Inter / Source Serif 4 are kept as graceful fallbacks within each stack. +export const AKTIV_FONT = '"Aktiv Grotesk App", "Inter", sans-serif' as const; +export const UNTITLED_SERIF_FONT = '"Untitled Serif", "Source Serif 4", serif' as const; +// Retained for backwards compatibility / explicit non-brand selections. export const INTER_FONT = '"Inter", sans-serif' as const; export const SOURCE_SERIF_FONT = '"Source Serif 4", serif' as const; -export type FontFamily = typeof INTER_FONT | typeof SOURCE_SERIF_FONT | (string & {}); +export type FontFamily = + | typeof AKTIV_FONT + | typeof UNTITLED_SERIF_FONT + | typeof INTER_FONT + | typeof SOURCE_SERIF_FONT + | (string & {}); diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index dc2f05cc..102cd8c0 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -66,20 +66,41 @@ layer(yv-sdk-fonts); format('woff2'); } +/* Untitled Serif — YouVersion brand serif (default --yv-font-serif). Served from prod + CDN. Regular (400) for body/serif text, Bold (700) for the abbreviation tile. Same CSP + note as Aktiv Grotesk: consumers with a strict font-src must allowlist + storage.googleapis.com, else this falls back to Source Serif 4. */ +@font-face { + font-family: 'Untitled Serif'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('https://storage.googleapis.com/cdn-youversion-com/fonts/untitled-serif/Untitled%20Serif.woff2') + format('woff2'); +} +@font-face { + font-family: 'Untitled Serif'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('https://storage.googleapis.com/cdn-youversion-com/fonts/untitled-serif/Untitled%20Serif%20Bold.woff2') + format('woff2'); +} + /* #region shadcn UI theme */ @custom-variant dark (&:is([data-yv-sdk][data-yv-theme='dark'] *)); @layer yv-sdk-theme { [data-yv-sdk] { @theme inline { - --font-sans: 'Inter', sans-serif; - --font-serif: 'Source Serif 4', serif; - /* Aktiv Grotesk App — YouVersion brand font for Book/Chapter picker (CDN @font-face above). - Falls back to --yv-font-sans (Inter) if the woff2 fails to load. - NOTE: must use --yv-font-sans (a real runtime var), NOT --font-sans — the latter is - declared via `@theme inline` and is inlined into utilities, so it is not emitted as a - runtime custom property; referencing var(--font-sans) here would invalidate the value. */ - --font-aktiv: 'Aktiv Grotesk App', var(--yv-font-sans); + /* YouVersion brand fonts are the defaults (CDN @font-face above), with Inter / + Source Serif 4 as graceful fallbacks if the woff2 files fail to load. */ + --font-sans: 'Aktiv Grotesk App', 'Inter', sans-serif; + --font-serif: 'Untitled Serif', 'Source Serif 4', serif; + /* Aliases kept for explicit brand-font utilities (yv:font-aktiv / yv:font-untitled-serif). + Now equivalent to the sans/serif defaults above. */ + --font-aktiv: var(--yv-font-sans); + --font-untitled-serif: var(--yv-font-serif); --color-background: var(--yv-background); --color-foreground: var(--yv-foreground); --color-card: var(--yv-card); diff --git a/packages/ui/src/test/mock-data/organizations.json b/packages/ui/src/test/mock-data/organizations.json new file mode 100644 index 00000000..7a184694 --- /dev/null +++ b/packages/ui/src/test/mock-data/organizations.json @@ -0,0 +1,26 @@ +{ + "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff": { + "id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff", + "name": "Biblica", + "primary_language": "en", + "website_url": "https://www.biblica.com" + }, + "798d8fa4-f640-4155-8cfb-fa91d1d8a06c": { + "id": "798d8fa4-f640-4155-8cfb-fa91d1d8a06c", + "name": "The Lockman Foundation", + "primary_language": "en", + "website_url": "https://www.lockman.org" + }, + "f2ac19b4-768c-4564-acaf-f28724235ad0": { + "id": "f2ac19b4-768c-4564-acaf-f28724235ad0", + "name": "Artists for Israel International", + "primary_language": "en", + "website_url": "https://www.afii.org" + }, + "f819451a-bc31-4037-b4bd-0c4aa00fe0f6": { + "id": "f819451a-bc31-4037-b4bd-0c4aa00fe0f6", + "name": "Cambridge University Press", + "primary_language": "en", + "website_url": "https://www.cambridge.org/bibles" + } +} diff --git a/packages/ui/src/test/mocks/handlers.ts b/packages/ui/src/test/mocks/handlers.ts index 1ae4baed..49ddfc9c 100644 --- a/packages/ui/src/test/mocks/handlers.ts +++ b/packages/ui/src/test/mocks/handlers.ts @@ -4,8 +4,20 @@ import { mockChapters } from '../mock-data/chapters'; import mockPassages from '../mock-data/passages.json'; import mockBibles from '../mock-data/bibles.json'; import mockLanguages from '../mock-data/languages.json'; +import mockOrganizations from '../mock-data/organizations.json'; export const globalHandlers = [ + // Organization (publisher) lookup for the version picker + http.get('*/v1/organizations/:id', ({ params }) => { + const id = params.id as string; + const organization = mockOrganizations[id as keyof typeof mockOrganizations]; + + if (organization) { + return HttpResponse.json(organization); + } + + return new HttpResponse(null, { status: 404 }); + }), // Specific Bible passages http.get('*/v1/bibles/111/passages/LUK.1.39-45', () => { return HttpResponse.json(mockPassages['LUK.1.39-45.NIV']);