Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bible-version-picker-figma-updates.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Changeset documents unlicensed fonts as a shipped feature

The changeset describes brand fonts (--yv-font-sans: 'Aktiv Grotesk App', --yv-font-serif: 'Untitled Serif') as part of the release. Since the PR is explicitly blocked pending licensing, this entry will need to be rewritten before the branch can be merged — otherwise npm consumers will see documentation for fonts they legally cannot use via SDK distribution. The changeset should either be split (font-only vs. non-font work) or have the brand-font references stripped out.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .changeset/bible-version-picker-figma-updates.md
Line: 1-11

Comment:
**Changeset documents unlicensed fonts as a shipped feature**

The changeset describes brand fonts (`--yv-font-sans: 'Aktiv Grotesk App'`, `--yv-font-serif: 'Untitled Serif'`) as part of the release. Since the PR is explicitly blocked pending licensing, this entry will need to be rewritten before the branch can be merged — otherwise npm consumers will see documentation for fonts they legally cannot use via SDK distribution. The changeset should either be split (font-only vs. non-font work) or have the brand-font references stripped out.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Cursor Fix in Codex

17 changes: 17 additions & 0 deletions packages/core/src/__tests__/MockOrganizations.ts
Original file line number Diff line number Diff line change
@@ -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];
12 changes: 12 additions & 0 deletions packages/core/src/__tests__/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/__tests__/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/organizations.ts
Original file line number Diff line number Diff line change
@@ -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<Organization> {
const parsedOrganizationId = OrganizationsClient.organizationIdSchema.parse(organizationId);
const organization = await this.client.get<Organization>(
`/v1/organizations/${parsedOrganizationId}`,
);

return OrganizationSchema.parse(organization);
}
}
7 changes: 5 additions & 2 deletions packages/core/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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'] {
Expand Down
3 changes: 3 additions & 0 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
139 changes: 139 additions & 0 deletions packages/hooks/src/useOrganization.test.tsx
Original file line number Diff line number Diff line change
@@ -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<OrganizationsClient> = { 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);
});
});
});
});
33 changes: 33 additions & 0 deletions packages/hooks/src/useOrganization.ts
Original file line number Diff line number Diff line change
@@ -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<Organization>(
() => organizationsClient.getOrganization(organizationId),
[organizationsClient, organizationId],
{
enabled,
},
);

return {
organization: data,
loading,
error,
refetch,
};
}
Loading
Loading