diff --git a/.changeset/bible-version-picker-figma-updates.md b/.changeset/bible-version-picker-figma-updates.md new file mode 100644 index 00000000..3d868f1f --- /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`. Design tokens use Inter (`--yv-font-sans`) and Source Serif 4 (`--yv-font-serif`); the YouVersion brand fonts (Aktiv Grotesk App / Untitled Serif) are reverted pending licensing — see `docs/adr/0001-revert-brand-fonts-pending-licensing.md`. +- `@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`: `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 (Source Serif 4) 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. Brand fonts (Aktiv Grotesk App / Untitled Serif) are reverted to Inter / Source Serif 4 pending licensing; the brand-font implementation is parked on branch `feat/youversion-brand-fonts`. diff --git a/docs/adr/0001-revert-brand-fonts-pending-licensing.md b/docs/adr/0001-revert-brand-fonts-pending-licensing.md new file mode 100644 index 00000000..656ef217 --- /dev/null +++ b/docs/adr/0001-revert-brand-fonts-pending-licensing.md @@ -0,0 +1,67 @@ +# 1. Revert brand fonts to Inter / Source Serif 4 pending licensing + +Date: 2026-06-24 + +## Status + +Accepted + +## Context + +The SDK had begun shipping YouVersion brand fonts to consumer apps: + +- **Aktiv Grotesk App** (Dalton Maag) as the sans default (`--yv-font-sans`), + loaded via a hardcoded `@font-face` pointing at a public CDN woff2. +- **Untitled Serif** (Klim Type Foundry) as the serif default (`--yv-font-serif`) + and the Bible Version picker abbreviation tile, same hardcoded `@font-face` pattern. + +Both create the same exposure: the SDK's purpose is to render fonts inside +**third-party developer apps**, so the font files are delivered to, and +downloadable by, third parties. + +- **Aktiv Grotesk (Dalton Maag):** the licence is breached the moment a + third-party developer uses their app key and gains access to the actual font + file (`.woff`/`.woff2`/`.otf`/`.ttf`). No licence tier we hold covers serving + this font to arbitrary third parties. CORS / file-level protection is + enforced server-side (YouVersion API gateway + CDN), not in the SDK — the SDK + cannot make the file un-downloadable. +- **Untitled Serif (Klim):** an Enterprise licence may permit third-party use + if developers qualify as a "partner" (the licence enumerates affiliates, + agencies, partners, vendors, contractors, freelancers). Whether a Platform + developer is a "partner" is an **open legal question**. + +A "browser-consumable stylesheet" endpoint exists +(`GET /v1/fonts/{font_id}/stylesheet`, accepts `app_key`, gateway injects the +app-id header). It is the correct future consumption pattern, but it does **not** +by itself resolve licensing: the woff2 it references still sits at a public CDN +URL, so switching to it does not make the font file un-downloadable. + +## Decision + +Revert **both** brand fonts to the prior fallbacks for the shipping PR: + +- `--yv-font-sans` → `'Inter', sans-serif` +- `--yv-font-serif` → `'Source Serif 4', serif` + +Remove both brand `@font-face` blocks, the `--font-aktiv` / `--font-untitled-serif` +aliases, the `yv:font-aktiv` / `yv:font-untitled-serif` usages, and the brand +options in the Bible Reader font picker. The abbreviation-tile redesign and all +other Figma layout/typography work, the `useOrganizations` hooks, and publisher +names are retained — only the font **family** is reverted. + +The brand-font implementation is parked on branch `feat/youversion-brand-fonts` +(snapshot at the pre-revert HEAD) for re-application once licensing clears. + +## Consequences + +- The SDK ships no licence-restricted font files to third parties. Defensible + legal state. +- The abbreviation tile and serif body text render in **Source Serif 4** (the + serif fallback) rather than Untitled Serif — closest legal match to the Figma + serif intent; exact brand match is deferred. +- Re-introducing brand fonts requires: (1) legal sign-off on Untitled Serif's + "partner" classification and/or a resolved Aktiv licence path, and (2) loading + via the gated `/v1/fonts/{font_id}/stylesheet` endpoint rather than hardcoded + `@font-face`. Untitled Serif is `font_id` 1 / slug `untitled-serif`. +- Re-application path: cherry-pick the font hunks from `feat/youversion-brand-fonts` + onto then-current `main`. 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..8fda5aeb 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -108,6 +108,9 @@ --yv-sidebar-border: var(--yv-gray-15); --yv-sidebar-ring: var(--yv-blue-30); + /* Brand fonts (Aktiv Grotesk App / Untitled Serif) reverted to Inter / Source + Serif 4 pending licensing — see docs/adr/0001-revert-brand-fonts-pending-licensing.md. + Brand-font implementation parked on branch feat/youversion-brand-fonts. */ --yv-font-sans: 'Inter', sans-serif; --yv-font-serif: 'Source Serif 4', serif; --yv-reader-font-family: var(--yv-font-serif), var(--yv-font-sans); 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..8e3e14a4 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -197,7 +197,7 @@ describe('BibleChapterPicker.Content onSelect', () => { }); }); -describe('BibleChapterPicker - typography (matches Figma: Aktiv Grotesk App)', () => { +describe('BibleChapterPicker - typography (matches Figma sizing; sans inherited)', () => { beforeEach(() => { vi.clearAllMocks(); setupDefaultMocks(); @@ -224,13 +224,13 @@ describe('BibleChapterPicker - typography (matches Figma: Aktiv Grotesk App)', ( ); } - it('book row uses Aktiv 16px, regular collapsed and bold when expanded', () => { + it('book row uses 16px, regular collapsed and bold when expanded', () => { renderContent(); // 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..18544262 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -49,7 +49,7 @@ const meta: Meta = { }, fontFamily: { control: 'select', - options: [SOURCE_SERIF_FONT, INTER_FONT, "'Georgia', serif", "'Nunito Sans', sans-serif"], + options: [SOURCE_SERIF_FONT, INTER_FONT], description: 'Font family', }, showVerseNumbers: { @@ -73,7 +73,7 @@ export const Default: Story = { args: { defaultVersionId: 111, lineSpacing: 1.7, - fontFamily: "'Inter', sans-serif", + fontFamily: INTER_FONT, showVerseNumbers: true, }, render: (args) => ( @@ -147,7 +147,7 @@ export const DarkTheme: Story = { defaultVersionId: 111, fontSize: 16, lineSpacing: 1.7, - fontFamily: "'Inter', sans-serif", + fontFamily: INTER_FONT, showVerseNumbers: true, }, globals: { @@ -172,7 +172,7 @@ export const CustomStyling: Story = { defaultVersionId: 111, fontSize: 18, lineSpacing: 2.0, - fontFamily: "'Nunito Sans', sans-serif", + fontFamily: SOURCE_SERIF_FONT, showVerseNumbers: false, }, render: (args) => ( @@ -202,7 +202,7 @@ export const FontSizeOutOfRange: Story = { defaultVersionId: 111, fontSize: 28, lineSpacing: 2.0, - fontFamily: "'Nunito Sans', sans-serif", + fontFamily: SOURCE_SERIF_FONT, showVerseNumbers: false, }, render: (args) => ( diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 6818abaa..64b39c88 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, @@ -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', () => { diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 395c02d3..28153209 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', @@ -141,6 +148,10 @@ function setupDefaultMocks({ vi.mocked(useFilteredVersions).mockReturnValue(filteredVersions); + vi.mocked(useOrganizations).mockReturnValue({ + organizations: new Map([[mockOrganization.id, mockOrganization]]), + }); + vi.mocked(useTheme).mockReturnValue('light'); } @@ -296,6 +307,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, 8px radius, warm-neutral fill, themed border + expect(media!.className).toContain('yv:size-16'); + expect(media!.className).toContain('yv:rounded-[8px]'); + 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 () => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => + key === RECENT_VERSIONS_KEY + ? JSON.stringify([ + { + id: 111, + title: 'New International Version', + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + }, + ]) + : null, + ); + + 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-[8px]'); + }); + }); + 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 4afdf1ed..06eec81c 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 []; @@ -74,6 +78,16 @@ function filterLanguagesBySearch(languages: LanguageListItem[], query: string): }); } +// 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 }) { @@ -141,7 +155,7 @@ function VersionAbbreviationIcon({ text }: { text: string }) { return (
{prefix} @@ -578,6 +592,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({ @@ -585,6 +608,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) title: version.title, localized_abbreviation: version.localized_abbreviation, abbreviation: version.abbreviation, + organization_id: version.organization_id, }); setSearchQuery(''); onRequestClose?.(); @@ -692,11 +716,12 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) > + {version.title} @@ -731,11 +756,12 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) > + {version.title} diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index dc2f05cc..f60accd8 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -45,26 +45,10 @@ layer(yv-sdk-fonts); @import '@youversion/platform-core/browser/styles/bible-reader.css' layer(yv-sdk-bible-reader); @import 'tw-animate-css'; -/* Aktiv Grotesk App — YouVersion brand font (Book/Chapter picker). Served from prod CDN. - Declared at top level (not in a cascade layer) — @font-face is not subject to @layer. - CSP: consumers with a strict font-src must allowlist storage.googleapis.com (see README - "Content Security Policy"), else this falls back to --yv-font-sans. */ -@font-face { - font-family: 'Aktiv Grotesk App'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url('https://storage.googleapis.com/cdn-youversion-com/fonts/aktiv-grotesk/AktivGrotesk_A_Rg.woff2') - format('woff2'); -} -@font-face { - font-family: 'Aktiv Grotesk App'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url('https://storage.googleapis.com/cdn-youversion-com/fonts/aktiv-grotesk/AktivGrotesk_A_Bd.woff2') - format('woff2'); -} +/* Brand fonts (Aktiv Grotesk App / Untitled Serif @font-face) removed pending licensing — + see docs/adr/0001-revert-brand-fonts-pending-licensing.md. Sans/serif now resolve to + Inter / Source Serif 4 (loaded via the Google Fonts @import above). Brand-font + implementation parked on branch feat/youversion-brand-fonts. */ /* #region shadcn UI theme */ @custom-variant dark (&:is([data-yv-sdk][data-yv-theme='dark'] *)); @@ -72,14 +56,10 @@ layer(yv-sdk-fonts); @layer yv-sdk-theme { [data-yv-sdk] { @theme inline { + /* Brand fonts reverted to Inter / Source Serif 4 pending licensing — see + docs/adr/0001-revert-brand-fonts-pending-licensing.md. */ --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); --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']);