feat(characters): enable isolated pre-release role switching
Docker Image / Quality and Bundle Budgets (push) Successful in 4s
Docker Image / Build and Push Docker Image (push) Successful in 1m52s

This commit is contained in:
Codex
2026-07-20 11:03:41 +08:00
parent 00ec1aead0
commit bc84d2c5d6
17 changed files with 194 additions and 47 deletions
+15 -15
View File
@@ -16,7 +16,7 @@
| 类型 | Elio 示例 | 用途 | | 类型 | Elio 示例 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 | | `id` | `elio` | 数据库关联、API 请求和缓存隔离 |
| `slug` | `elio` | 前端 URL 和外部入口 | | `slug` | `elio` | 前端 URL 和外部入口 |
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。 后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
@@ -37,9 +37,9 @@
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner | | `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` | | `elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` | | `maya-tan` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` | | `nayeli-cervantes` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。 当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
@@ -56,7 +56,7 @@ POST /api/chat/unlock-private
POST /api/chat/unlock-history POST /api/chat/unlock-history
``` ```
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。 `characterId` 使用前端本地角色目录中的 `id`,例如 `elio`,不能传 slug 或显示名称。
后端必须使用以下维度定位聊天数据: 后端必须使用以下维度定位聊天数据:
@@ -79,7 +79,7 @@ Authorization: Bearer <TOKEN>
```json ```json
{ {
"characterId": "character_elio", "characterId": "elio",
"message": "Hello Elio", "message": "Hello Elio",
"image": "", "image": "",
"imageId": "", "imageId": "",
@@ -105,7 +105,7 @@ Authorization: Bearer <TOKEN>
### 3.3 获取聊天历史 ### 3.3 获取聊天历史
```http ```http
GET /api/chat/history?characterId=character_elio&limit=50&offset=0 GET /api/chat/history?characterId=elio&limit=50&offset=0
Authorization: Bearer <TOKEN> Authorization: Bearer <TOKEN>
``` ```
@@ -131,7 +131,7 @@ Authorization: Bearer <TOKEN>
```json ```json
{ {
"characterId": "character_elio", "characterId": "elio",
"messageId": "message-id", "messageId": "message-id",
"lockType": "voice_message", "lockType": "voice_message",
"clientLockId": "client-generated-id" "clientLockId": "client-generated-id"
@@ -164,7 +164,7 @@ Authorization: Bearer <TOKEN>
```json ```json
{ {
"characterId": "character_elio" "characterId": "elio"
} }
``` ```
@@ -190,7 +190,7 @@ Authorization: Bearer <TOKEN>
"data": { "data": {
"items": [ "items": [
{ {
"characterId": "character_elio", "characterId": "elio",
"message": { "message": {
"id": "message-id", "id": "message-id",
"role": "assistant", "role": "assistant",
@@ -220,7 +220,7 @@ Authorization: Bearer <TOKEN>
标准请求改为: 标准请求改为:
```http ```http
GET /api/private-room/albums?characterId=character_elio&limit=20 GET /api/private-room/albums?characterId=elio&limit=20
Authorization: Bearer <TOKEN> Authorization: Bearer <TOKEN>
``` ```
@@ -266,7 +266,7 @@ POST /api/payment/create-order
"planId": "tip_coffee_medium", "planId": "tip_coffee_medium",
"payChannel": "stripe", "payChannel": "stripe",
"autoRenew": false, "autoRenew": false,
"recipientCharacterId": "character_elio" "recipientCharacterId": "elio"
} }
``` ```
@@ -326,9 +326,9 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
上线新接口前执行: 上线新接口前执行:
1. 创建 Elio 角色,固定 `id=character_elio``slug=elio` 1. 创建 Elio 角色,固定 `id=elio``slug=elio`
2. 为相关业务表增加可空 `character_id` 2. 为相关业务表增加可空 `character_id`
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio` 3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `elio`
4. 检查不存在空角色或无法关联的数据。 4. 检查不存在空角色或无法关联的数据。
5. 创建联合索引和外键。 5. 创建联合索引和外键。
6. 将必要业务表的 `character_id` 调整为非空。 6. 将必要业务表的 `character_id` 调整为非空。
@@ -340,7 +340,7 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
### 第一阶段:后端兼容 ### 第一阶段:后端兼容
- 上线角色 ID 配置和新字段。 - 上线角色 ID 配置和新字段。
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio` - Chat 请求缺少 `characterId` 时暂时默认 `elio`
- Private Room 缺少角色时暂时默认 Elio。 - Private Room 缺少角色时暂时默认 Elio。
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。 - Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
- 记录缺少角色参数的调用次数和客户端版本。 - 记录缺少角色参数的调用次数和客户端版本。
@@ -0,0 +1,34 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { CHARACTERS, getCharacterBySlug } from "@/data/constants/character";
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
import { CharacterProvider } from "@/providers/character-provider";
import { CharacterSwitcher } from "../character-switcher";
describe("CharacterSwitcher", () => {
it("links every chat character and marks the active account conversation", () => {
const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull();
const html = renderToStaticMarkup(
<CharacterCatalogProvider characters={CHARACTERS}>
<CharacterProvider character={maya!}>
<CharacterSwitcher />
</CharacterProvider>
</CharacterCatalogProvider>,
);
expect(html).toContain('aria-label="Choose character"');
expect(html).toContain('href="/characters/elio/splash"');
expect(html).toContain('href="/characters/maya/splash"');
expect(html).toContain('href="/characters/nayeli/splash"');
expect(html).toContain('data-character-id="elio"');
expect(html).toContain('data-character-id="maya-tan"');
expect(html).toContain('data-character-id="nayeli-cervantes"');
expect(html).toMatch(
/<a(?=[^>]*data-character-id="maya-tan")(?=[^>]*aria-current="page")[^>]*>/,
);
});
});
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { getCharacterRoutes } from "@/router/routes";
import { useActiveCharacter } from "@/providers/character-provider";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { CharacterAvatar } from "./character-avatar";
export interface CharacterSwitcherProps {
destination?: "splash" | "chat";
}
export function CharacterSwitcher({
destination = "splash",
}: CharacterSwitcherProps) {
const activeCharacter = useActiveCharacter();
const catalog = useCharacterCatalog();
const chatCharacters = catalog.characters.filter(
(character) => character.capabilities.chat,
);
return (
<nav
aria-label="Choose character"
className="grid w-full grid-cols-3 gap-1 rounded-lg border border-white/15 bg-black/35 p-1 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-md"
>
{chatCharacters.map((character) => {
const isActive = character.id === activeCharacter.id;
const routes = getCharacterRoutes(character.slug);
return (
<Link
key={character.id}
href={routes[destination]}
aria-current={isActive ? "page" : undefined}
aria-label={`Chat with ${character.displayName}`}
title={character.displayName}
data-character-id={character.id}
data-analytics-key="character.switch"
data-analytics-label={`Switch to ${character.displayName}`}
className={[
"flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1.5 py-1.5 text-[11px] font-semibold text-white transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
isActive
? "bg-white/20 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
: "bg-transparent hover:bg-white/10",
].join(" ")}
>
<CharacterAvatar
src={character.assets.avatar}
alt=""
size={28}
imageSize={56}
className={
isActive
? "ring-2 ring-white ring-offset-1 ring-offset-transparent"
: "opacity-85"
}
/>
<span className="min-w-0 truncate">{character.shortName}</span>
</Link>
);
})}
</nav>
);
}
@@ -93,6 +93,15 @@ describe("core Tailwind components", () => {
onPrivateRoomClick={() => undefined} onPrivateRoomClick={() => undefined}
/>, />,
); );
const disabledPrivateRoomHtml = renderToStaticMarkup(
<AppBottomNav
activeItem="chat"
privateRoomLabel="Maya Private room"
privateRoomEnabled={false}
onChatClick={() => undefined}
onPrivateRoomClick={() => undefined}
/>,
);
expect(chatHtml).toContain('aria-label="Primary navigation"'); expect(chatHtml).toContain('aria-label="Primary navigation"');
expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/); expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/);
@@ -107,5 +116,8 @@ describe("core Tailwind components", () => {
expect(privateRoomHtml).toContain( expect(privateRoomHtml).toContain(
'data-analytics-key="navigation.private_room"', 'data-analytics-key="navigation.private_room"',
); );
expect(disabledPrivateRoomHtml).toMatch(
/<button[^>]*aria-disabled="true"[^>]*disabled=""[^>]*>/,
);
}); });
}); });
@@ -80,6 +80,20 @@
outline-offset: 3px; outline-offset: 3px;
} }
.disabled {
cursor: not-allowed;
opacity: 0.42;
}
.disabled:hover {
background: transparent;
color: var(--nav-button-color);
}
.disabled:active {
transform: none;
}
.active { .active {
color: var(--nav-active-color); color: var(--nav-active-color);
} }
+16 -3
View File
@@ -11,6 +11,7 @@ export interface AppBottomNavProps {
activeItem?: AppBottomNavItem | null; activeItem?: AppBottomNavItem | null;
variant?: AppBottomNavVariant; variant?: AppBottomNavVariant;
privateRoomLabel: string; privateRoomLabel: string;
privateRoomEnabled?: boolean;
onChatClick: () => void; onChatClick: () => void;
onPrivateRoomClick: () => void; onPrivateRoomClick: () => void;
} }
@@ -19,6 +20,7 @@ export function AppBottomNav({
activeItem = null, activeItem = null,
variant = "warm", variant = "warm",
privateRoomLabel, privateRoomLabel,
privateRoomEnabled = true,
onChatClick, onChatClick,
onPrivateRoomClick, onPrivateRoomClick,
}: AppBottomNavProps) { }: AppBottomNavProps) {
@@ -39,8 +41,13 @@ export function AppBottomNav({
type="button" type="button"
data-analytics-key="navigation.private_room" data-analytics-key="navigation.private_room"
data-analytics-label="Private room navigation" data-analytics-label="Private room navigation"
className={getButtonClass(activeItem === "privateRoom")} className={getButtonClass(
activeItem === "privateRoom",
!privateRoomEnabled,
)}
aria-current={activeItem === "privateRoom" ? "page" : undefined} aria-current={activeItem === "privateRoom" ? "page" : undefined}
aria-disabled={!privateRoomEnabled}
disabled={!privateRoomEnabled}
onClick={onPrivateRoomClick} onClick={onPrivateRoomClick}
> >
<Camera size={20} aria-hidden="true" /> <Camera size={20} aria-hidden="true" />
@@ -54,6 +61,12 @@ function getRootClass(variant: AppBottomNavVariant): string {
return [styles.root, styles[variant]].filter(Boolean).join(" "); return [styles.root, styles[variant]].filter(Boolean).join(" ");
} }
function getButtonClass(active: boolean): string { function getButtonClass(active: boolean, disabled = false): string {
return [styles.button, active ? styles.active : ""].filter(Boolean).join(" "); return [
styles.button,
active ? styles.active : "",
disabled ? styles.disabled : "",
]
.filter(Boolean)
.join(" ");
} }
+5
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { CharacterSwitcher } from "@/app/_components/character-switcher";
import { AppBottomNav, MobileShell } from "@/app/_components/core"; import { AppBottomNav, MobileShell } from "@/app/_components/core";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { import {
@@ -57,6 +58,9 @@ export function SplashScreen() {
<div className={styles.gradientOverlay} aria-hidden="true" /> <div className={styles.gradientOverlay} aria-hidden="true" />
<div className={styles.content}> <div className={styles.content}>
<SplashLogo /> <SplashLogo />
<div className="mt-3 w-full max-w-120">
<CharacterSwitcher />
</div>
<div className={styles.spacer} /> <div className={styles.spacer} />
<SplashLatestMessage <SplashLatestMessage
message={latestMessage.message} message={latestMessage.message}
@@ -78,6 +82,7 @@ export function SplashScreen() {
<AppBottomNav <AppBottomNav
activeItem="chat" activeItem="chat"
privateRoomLabel={character.copy.privateRoomTitle} privateRoomLabel={character.copy.privateRoomTitle}
privateRoomEnabled={character.capabilities.privateRoom}
onChatClick={handleOpenSplash} onChatClick={handleOpenSplash}
onPrivateRoomClick={handleOpenPrivateRoom} onPrivateRoomClick={handleOpenPrivateRoom}
/> />
@@ -40,7 +40,7 @@ describe("local character catalog", () => {
}); });
it("resolves characters by id and normalized slug", () => { it("resolves characters by id and normalized slug", () => {
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan"); expect(getCharacterById("maya-tan")?.displayName).toBe("Maya Tan");
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe( expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
"Nayeli Cervantes", "Nayeli Cervantes",
); );
@@ -50,11 +50,11 @@ describe("local character catalog", () => {
it("describes route capabilities and character-specific chat copy", () => { it("describes route capabilities and character-specific chat copy", () => {
for (const character of CHARACTERS) { for (const character of CHARACTERS) {
expect(character.capabilities).toEqual({ expect(character.capabilities.chat).toBe(true);
chat: true, expect(character.capabilities.tip).toBe(true);
privateRoom: true, expect(character.capabilities.privateRoom).toBe(
tip: true, character.id === "elio",
}); );
expect(character.tagline.length).toBeGreaterThan(0); expect(character.tagline.length).toBeGreaterThan(0);
expect(character.emptyChatGreeting.length).toBeGreaterThan(0); expect(character.emptyChatGreeting.length).toBeGreaterThan(0);
expect(character.assets.chatBackground).toBe( expect(character.assets.chatBackground).toBe(
+5 -5
View File
@@ -45,7 +45,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile {
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
defineCharacter({ defineCharacter({
id: "character_elio", id: "elio",
slug: "elio", slug: "elio",
displayName: "Elio Silvestri", displayName: "Elio Silvestri",
shortName: "Elio", shortName: "Elio",
@@ -75,7 +75,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
}, },
}), }),
defineCharacter({ defineCharacter({
id: "character_maya", id: "maya-tan",
slug: "maya", slug: "maya",
displayName: "Maya Tan", displayName: "Maya Tan",
shortName: "Maya", shortName: "Maya",
@@ -86,7 +86,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
"How was your day?", "How was your day?",
capabilities: { capabilities: {
chat: true, chat: true,
privateRoom: true, privateRoom: false,
tip: true, tip: true,
}, },
assets: { assets: {
@@ -104,7 +104,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
}, },
}), }),
defineCharacter({ defineCharacter({
id: "character_nayeli", id: "nayeli-cervantes",
slug: "nayeli", slug: "nayeli",
displayName: "Nayeli Cervantes", displayName: "Nayeli Cervantes",
shortName: "Nayeli", shortName: "Nayeli",
@@ -115,7 +115,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
"Tell me how your day went.", "Tell me how your day went.",
capabilities: { capabilities: {
chat: true, chat: true,
privateRoom: true, privateRoom: false,
tip: true, tip: true,
}, },
assets: { assets: {
@@ -1,5 +1,5 @@
{ {
"characterId": "character_elio", "characterId": "elio",
"message": "Look at this sunset.", "message": "Look at this sunset.",
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...", "image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
"imageId": "img_mock_001", "imageId": "img_mock_001",
@@ -1,5 +1,5 @@
{ {
"characterId": "character_elio", "characterId": "elio",
"message": "I missed you today.", "message": "I missed you today.",
"image": "", "image": "",
"imageId": "", "imageId": "",
@@ -1,4 +1,4 @@
{ {
"characterId": "character_elio", "characterId": "elio",
"messageId": "msg_private_locked_001" "messageId": "msg_private_locked_001"
} }
@@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
expect(result).toEqual(Result.ok(null)); expect(result).toEqual(Result.ok(null));
expect(getMedia).toHaveBeenCalledTimes(1); expect(getMedia).toHaveBeenCalledTimes(1);
expect(getMedia).toHaveBeenCalledWith( expect(getMedia).toHaveBeenCalledWith(
"user:account-a::character:character_elio:message-1:image", "user:account-a::character:elio:message-1:image",
); );
}); });
@@ -46,19 +46,19 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
); );
await coordinator.getCachedMedia({ await coordinator.getCachedMedia({
characterId: "character_elio", characterId: "elio",
messageId: "shared-message-id", messageId: "shared-message-id",
kind: "image", kind: "image",
}); });
await coordinator.getCachedMedia({ await coordinator.getCachedMedia({
characterId: "character_aria", characterId: "maya-tan",
messageId: "shared-message-id", messageId: "shared-message-id",
kind: "image", kind: "image",
}); });
expect(getMedia.mock.calls).toEqual([ expect(getMedia.mock.calls).toEqual([
["user:account-a::character:character_elio:shared-message-id:image"], ["user:account-a::character:elio:shared-message-id:image"],
["user:account-a::character:character_aria:shared-message-id:image"], ["user:account-a::character:maya-tan:shared-message-id:image"],
]); ]);
}); });
@@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
kind: "image", kind: "image",
remoteUrl: "https://media.example/expired.jpg", remoteUrl: "https://media.example/expired.jpg",
}, },
"user:account-a::character:character_elio", "user:account-a::character:elio",
); );
expect(Result.isErr(result)).toBe(true); expect(Result.isErr(result)).toBe(true);
@@ -15,7 +15,7 @@ vi.mock("../http_client", () => ({
import { ChatApi } from "../chat_api"; import { ChatApi } from "../chat_api";
import { PrivateRoomApi } from "../private_room_api"; import { PrivateRoomApi } from "../private_room_api";
const CHARACTER_ID = "character_elio"; const CHARACTER_ID = "elio";
describe("multi-character API contract", () => { describe("multi-character API contract", () => {
beforeEach(() => { beforeEach(() => {
@@ -32,7 +32,7 @@ describe("fetchSplashLatestMessagePreview", () => {
const result = await fetchSplashLatestMessagePreview(); const result = await fetchSplashLatestMessagePreview();
expect(getHistoryMock).toHaveBeenCalledWith( expect(getHistoryMock).toHaveBeenCalledWith(
"character_elio", "elio",
1, 1,
0, 0,
); );
+5 -1
View File
@@ -24,13 +24,17 @@ export function CharacterProvider({
} }
export function useActiveCharacter(): CharacterProfile { export function useActiveCharacter(): CharacterProfile {
const character = useContext(CharacterContext); const character = useOptionalActiveCharacter();
if (!character) { if (!character) {
throw new Error("useActiveCharacter must be used within CharacterProvider."); throw new Error("useActiveCharacter must be used within CharacterProvider.");
} }
return character; return character;
} }
export function useOptionalActiveCharacter(): CharacterProfile | null {
return useContext(CharacterContext);
}
export function useActiveCharacterRoutes(): CharacterRoutes { export function useActiveCharacterRoutes(): CharacterRoutes {
return getCharacterRoutes(useActiveCharacter().slug); return getCharacterRoutes(useActiveCharacter().slug);
} }
+5 -6
View File
@@ -16,12 +16,11 @@ import {
} from "@/lib/analytics"; } from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context"; import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import { import { DEFAULT_CHARACTER } from "@/data/constants/character";
useActiveCharacter, import { useOptionalActiveCharacter } from "@/providers/character-provider";
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { import {
getCharacterRoutes,
ROUTE_BUILDERS, ROUTE_BUILDERS,
type Route, type Route,
} from "./routes"; } from "./routes";
@@ -60,8 +59,8 @@ export interface AppNavigator {
export function useAppNavigator(): AppNavigator { export function useAppNavigator(): AppNavigator {
const router = useRouter(); const router = useRouter();
const character = useActiveCharacter(); const character = useOptionalActiveCharacter() ?? DEFAULT_CHARACTER;
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = getCharacterRoutes(character.slug);
const loginStatus = useAuthSelector((state) => state.context.loginStatus); const loginStatus = useAuthSelector((state) => state.context.loginStatus);
const countryCode = useUserSelector( const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode, (state) => state.context.currentUser?.countryCode,