feat(characters): use local character catalog
This commit is contained in:
@@ -10,6 +10,7 @@ export type AppBottomNavVariant = "warm" | "dark";
|
||||
export interface AppBottomNavProps {
|
||||
activeItem?: AppBottomNavItem | null;
|
||||
variant?: AppBottomNavVariant;
|
||||
privateRoomLabel?: string;
|
||||
onChatClick: () => void;
|
||||
onPrivateRoomClick: () => void;
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export interface AppBottomNavProps {
|
||||
export function AppBottomNav({
|
||||
activeItem = null,
|
||||
variant = "warm",
|
||||
privateRoomLabel = "Elio Private room",
|
||||
onChatClick,
|
||||
onPrivateRoomClick,
|
||||
}: AppBottomNavProps) {
|
||||
@@ -42,7 +44,7 @@ export function AppBottomNav({
|
||||
onClick={onPrivateRoomClick}
|
||||
>
|
||||
<Camera size={20} aria-hidden="true" />
|
||||
<span>Elio Private room</span>
|
||||
<span>{privateRoomLabel}</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface UsePaymentLaunchFlowInput {
|
||||
payment: PaymentContextState;
|
||||
paymentDispatch: Dispatch<PaymentEvent>;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
characterSlug?: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
}
|
||||
@@ -107,6 +108,7 @@ export function usePaymentLaunchFlow({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
returnTo,
|
||||
characterSlug,
|
||||
subscriptionType,
|
||||
tipCoffeeType,
|
||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||
@@ -153,6 +155,7 @@ export function usePaymentLaunchFlow({
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
|
||||
onFailed: (errorMessage) => {
|
||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||
@@ -183,6 +186,7 @@ export function usePaymentLaunchFlow({
|
||||
}, [
|
||||
log,
|
||||
logScope,
|
||||
characterSlug,
|
||||
payment.currentOrderId,
|
||||
payment,
|
||||
payment.launchNonce,
|
||||
@@ -236,6 +240,7 @@ export function usePaymentLaunchFlow({
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
...(characterSlug ? { characterSlug } : {}),
|
||||
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
|
||||
onFailed: (errorMessage) => {
|
||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getCharacterBySlug } from "@/data/constants/character";
|
||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||
|
||||
export default async function CharacterChatLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ characterSlug: string }>;
|
||||
}) {
|
||||
const { characterSlug } = await params;
|
||||
const character = getCharacterBySlug(characterSlug);
|
||||
if (!character) notFound();
|
||||
|
||||
return (
|
||||
<ChatRouteProviders characterId={character.id}>
|
||||
{children}
|
||||
</ChatRouteProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||
|
||||
export default function CharacterChatPage() {
|
||||
return (
|
||||
<Suspense fallback={<ChatFallback />}>
|
||||
<ChatScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatFallback() {
|
||||
return (
|
||||
<PageLoadingFallback background="#111111" tone="dark">
|
||||
Loading chat...
|
||||
</PageLoadingFallback>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CoinsRulesScreen } from "@/app/coins-rules/coins-rules-screen";
|
||||
|
||||
export default function CharacterCoinsRulesPage() {
|
||||
return <CoinsRulesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FeedbackScreen } from "@/app/feedback/feedback-screen";
|
||||
|
||||
export default function CharacterFeedbackPage() {
|
||||
return <FeedbackScreen />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
CHARACTERS,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
import { CharacterProvider } from "@/providers/character-provider";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return CHARACTERS.map((character) => ({
|
||||
characterSlug: character.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function CharacterLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ characterSlug: string }>;
|
||||
}) {
|
||||
const { characterSlug } = await params;
|
||||
const character = getCharacterBySlug(characterSlug);
|
||||
if (!character) notFound();
|
||||
|
||||
return <CharacterProvider character={character}>{children}</CharacterProvider>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getCharacterBySlug } from "@/data/constants/character";
|
||||
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
||||
|
||||
export default async function CharacterPrivateRoomLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ characterSlug: string }>;
|
||||
}) {
|
||||
const { characterSlug } = await params;
|
||||
const character = getCharacterBySlug(characterSlug);
|
||||
if (!character) notFound();
|
||||
|
||||
return (
|
||||
<PrivateRoomRouteProvider characterId={character.id}>
|
||||
{children}
|
||||
</PrivateRoomRouteProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrivateRoomScreen } from "@/app/private-room/private-room-screen";
|
||||
|
||||
export default function CharacterPrivateRoomPage() {
|
||||
return <PrivateRoomScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SidebarScreen } from "@/app/sidebar/sidebar-screen";
|
||||
|
||||
export default function CharacterSidebarPage() {
|
||||
return <SidebarScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SplashScreen } from "@/app/splash/splash-screen";
|
||||
|
||||
export default function CharacterSplashPage() {
|
||||
return <SplashScreen />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||
|
||||
export default function CharacterTipLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentReturnSearchParams,
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
resolveTipCoffeeType,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { TipScreen } from "@/app/tip/tip-screen";
|
||||
|
||||
export default async function CharacterTipPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<PaymentSearchParams>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const paymentReturn = parsePaymentReturnSearchParams(query);
|
||||
const coffeeType =
|
||||
resolveTipCoffeeType(
|
||||
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
||||
) ?? DEFAULT_TIP_COFFEE_TYPE;
|
||||
|
||||
return (
|
||||
<TipScreen
|
||||
coffeeType={coffeeType}
|
||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||
initialPayChannel={paymentReturn.initialPayChannel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,18 +9,22 @@ export function getChatImageOverlayMessageId(input: {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function buildChatImageOverlayUrl(messageId: string): `/chat?${string}` {
|
||||
export function buildChatImageOverlayUrl(
|
||||
messageId: string,
|
||||
basePath: string = ROUTES.chat,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
[CHAT_IMAGE_QUERY_PARAM]: messageId,
|
||||
});
|
||||
return `${ROUTES.chat}?${params.toString()}` as const;
|
||||
return `${basePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildChatWithoutImageOverlayUrl(input: {
|
||||
toString: () => string;
|
||||
}): string {
|
||||
export function buildChatWithoutImageOverlayUrl(
|
||||
input: { toString: () => string },
|
||||
basePath: string = ROUTES.chat,
|
||||
): string {
|
||||
const params = new URLSearchParams(input.toString());
|
||||
params.delete(CHAT_IMAGE_QUERY_PARAM);
|
||||
const query = params.toString();
|
||||
return query ? `${ROUTES.chat}?${query}` : ROUTES.chat;
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useChatDispatch,
|
||||
useChatState,
|
||||
} from "@/stores/chat/chat-context";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
|
||||
@@ -46,6 +46,7 @@ const chatShellStyle = {
|
||||
|
||||
export function ChatScreen() {
|
||||
const router = useRouter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const searchParams = useSearchParams();
|
||||
const state = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
@@ -63,10 +64,10 @@ export function ChatScreen() {
|
||||
: state.historyMessages;
|
||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||
const imageReturnUrl = imageMessageId
|
||||
? buildChatImageOverlayUrl(imageMessageId)
|
||||
: ROUTES.chat;
|
||||
? buildChatImageOverlayUrl(imageMessageId, characterRoutes.chat)
|
||||
: characterRoutes.chat;
|
||||
const unlockCoordinator = useChatUnlockCoordinator({
|
||||
defaultReturnUrl: ROUTES.chat,
|
||||
defaultReturnUrl: characterRoutes.chat,
|
||||
imageMessageId,
|
||||
imageReturnUrl,
|
||||
promotion: state.promotion,
|
||||
@@ -113,10 +114,12 @@ export function ChatScreen() {
|
||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
}, [
|
||||
characterRoutes.chat,
|
||||
imageMessageId,
|
||||
router,
|
||||
searchParams,
|
||||
@@ -137,13 +140,16 @@ export function ChatScreen() {
|
||||
}
|
||||
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
||||
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseImageViewer(): void {
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
|
||||
function handleUnlockImagePaywall(): void {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getCharacterBySlug } from "@/data/constants/character";
|
||||
import { CharacterProvider } from "@/providers/character-provider";
|
||||
|
||||
import { BrowserHintOverlay } from "../browser-hint-overlay";
|
||||
import { ChatHeader } from "../chat-header";
|
||||
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
||||
@@ -35,6 +38,19 @@ describe("chat Tailwind components", () => {
|
||||
expect(userHtml).toContain("%2Fuser-avatar.png");
|
||||
});
|
||||
|
||||
it("renders the active character avatar", () => {
|
||||
const maya = getCharacterBySlug("maya");
|
||||
expect(maya).not.toBeNull();
|
||||
const html = renderToStaticMarkup(
|
||||
<CharacterProvider character={maya!}>
|
||||
<MessageAvatar isFromAI={true} />
|
||||
</CharacterProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("%2Fimages%2Favatar%2Fmaya.png");
|
||||
expect(html).toContain('alt="Maya Tan"');
|
||||
});
|
||||
|
||||
it("renders TextBubble AI and user visual variants", () => {
|
||||
const aiHtml = renderToStaticMarkup(
|
||||
<TextBubble content={"hello\nthere"} isFromAI={true} />,
|
||||
@@ -172,7 +188,7 @@ describe("chat Tailwind components", () => {
|
||||
expect(guestHtml).not.toContain('aria-label="Back to home"');
|
||||
expect(guestHtml).not.toContain('aria-label="Menu"');
|
||||
expect(memberHtml).toContain("Offer");
|
||||
expect(memberHtml).toContain('href="/splash"');
|
||||
expect(memberHtml).toContain('href="/characters/elio/splash"');
|
||||
expect(memberHtml).toContain('aria-label="Back to home"');
|
||||
expect(memberHtml).toContain('aria-label="Menu"');
|
||||
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
||||
import { Lock, Menu } from "lucide-react";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||||
@@ -26,6 +26,7 @@ export function ChatHeader({
|
||||
showBrowserHint = false,
|
||||
}: ChatHeaderProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||
@@ -35,7 +36,7 @@ export function ChatHeader({
|
||||
data-analytics-key="chat.open_signup"
|
||||
data-analytics-label="Open sign up"
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-accent px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
|
||||
onClick={() => navigator.openAuth(ROUTES.chat)}
|
||||
onClick={() => navigator.openAuth(characterRoutes.chat)}
|
||||
aria-label="Sign up to unlock more features"
|
||||
>
|
||||
<Lock
|
||||
@@ -52,7 +53,7 @@ export function ChatHeader({
|
||||
{!isGuest ? (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-(--spacing-sm,8px) px-(--spacing-md,12px) py-(--spacing-sm,8px)">
|
||||
<BackButton
|
||||
href={ROUTES.splash}
|
||||
href={characterRoutes.splash}
|
||||
variant="dark"
|
||||
ariaLabel="Back to home"
|
||||
analyticsKey="chat.back_to_home"
|
||||
@@ -67,7 +68,7 @@ export function ChatHeader({
|
||||
data-analytics-key="chat.open_menu"
|
||||
data-analytics-label="Open chat menu"
|
||||
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={() => navigator.push(ROUTES.sidebar)}
|
||||
onClick={() => navigator.push(characterRoutes.sidebar)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu size={24} aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface LockedImageMessageCardProps {
|
||||
hint?: string | null;
|
||||
@@ -13,6 +14,8 @@ export function LockedImageMessageCard({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: LockedImageMessageCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[min(72vw,280px)] overflow-hidden rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
||||
@@ -29,7 +32,7 @@ export function LockedImageMessageCard({
|
||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio sent you a locked image."}
|
||||
: `${character.shortName} sent you a locked image.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { UserMessageAvatar } from "@/app/_components";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface MessageAvatarProps {
|
||||
isFromAI: boolean;
|
||||
@@ -12,12 +13,14 @@ const AVATAR_CLASS_NAME =
|
||||
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
|
||||
|
||||
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
if (isFromAI) {
|
||||
return (
|
||||
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
|
||||
<Image
|
||||
src="/images/avatar/elio.png"
|
||||
alt="Elio"
|
||||
src={character.assets.avatar}
|
||||
alt={character.displayName}
|
||||
width={43}
|
||||
height={43}
|
||||
priority
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
|
||||
export interface PrivateMessageCardProps {
|
||||
hint?: string | null;
|
||||
@@ -13,6 +14,8 @@ export function PrivateMessageCard({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: PrivateMessageCardProps) {
|
||||
const character = useActiveCharacter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-[min(72vw,280px)] rounded-(--responsive-card-radius-sm,18px) rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-[linear-gradient(180deg,rgba(255,244,248,0.95),rgba(255,255,255,0.95)),#ffffff] p-(--responsive-card-padding,14px) text-[#3c3b3b] shadow-[0_4px_12px_rgba(246,87,160,0.12)]"
|
||||
@@ -28,7 +31,7 @@ export function PrivateMessageCard({
|
||||
<p className="m-0 text-(length:--responsive-body,14px) leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio has a private message for you."}
|
||||
: `${character.shortName} has a private message for you.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
@@ -27,6 +28,7 @@ export function VoiceBubble({
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: VoiceBubbleProps) {
|
||||
const character = useActiveCharacter();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
|
||||
@@ -123,7 +125,7 @@ export function VoiceBubble({
|
||||
<p className={styles.hint}>
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio has a locked voice message for you."}
|
||||
: `${character.shortName} has a locked voice message for you.`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||
|
||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
||||
return <ChatRouteProviders>{children}</ChatRouteProviders>;
|
||||
export default function LegacyChatLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
+17
-16
@@ -1,20 +1,21 @@
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { PageLoadingFallback } from "@/app/_components/core";
|
||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
appendRouteSearchParams,
|
||||
getCharacterRoutes,
|
||||
type RouteSearchParams,
|
||||
} from "@/router/routes";
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
<Suspense fallback={<ChatFallback />}>
|
||||
<ChatScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatFallback() {
|
||||
return (
|
||||
<PageLoadingFallback background="#111111" tone="dark">
|
||||
Loading chat...
|
||||
</PageLoadingFallback>
|
||||
export default async function ChatPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<RouteSearchParams>;
|
||||
}) {
|
||||
redirect(
|
||||
appendRouteSearchParams(
|
||||
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).chat,
|
||||
await searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FaCoins } from "react-icons/fa6";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||
|
||||
@@ -76,6 +76,7 @@ const createCoinRules = (
|
||||
] as const;
|
||||
|
||||
export function CoinsRulesScreen() {
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
@@ -110,7 +111,7 @@ export function CoinsRulesScreen() {
|
||||
<main className={styles.screen}>
|
||||
<header className={styles.header}>
|
||||
<BackButton
|
||||
href={ROUTES.sidebar}
|
||||
href={characterRoutes.sidebar}
|
||||
variant="soft"
|
||||
aria-label="Back to sidebar"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ExternalEntryPersistProps {
|
||||
psid: string | null;
|
||||
avatarUrl: string | null;
|
||||
target: string | null;
|
||||
character: string | null;
|
||||
mode: string | null;
|
||||
promotionType: string | null;
|
||||
}
|
||||
@@ -36,6 +37,7 @@ export default function ExternalEntryPersist({
|
||||
psid,
|
||||
avatarUrl,
|
||||
target,
|
||||
character,
|
||||
mode,
|
||||
promotionType,
|
||||
}: ExternalEntryPersistProps) {
|
||||
@@ -46,7 +48,7 @@ export default function ExternalEntryPersist({
|
||||
const hasNavigatedRef = useRef(false);
|
||||
const hasReinitializedForPsidRef = useRef(false);
|
||||
const targetRoute = resolveExternalEntryTarget({ target });
|
||||
const destination = resolveExternalEntryDestination({ target });
|
||||
const destination = resolveExternalEntryDestination({ target, character });
|
||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||
mode,
|
||||
promotionType,
|
||||
@@ -84,6 +86,7 @@ export default function ExternalEntryPersist({
|
||||
}, [
|
||||
avatarUrl,
|
||||
asid,
|
||||
character,
|
||||
destination,
|
||||
deviceId,
|
||||
mode,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
||||
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
||||
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
||||
* `/external-entry?target=chat&character=maya`
|
||||
* `/external-entry?target=tip`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
@@ -30,6 +31,7 @@ export default async function ExternalEntryPage({
|
||||
psid={pickParam(params.psid)}
|
||||
avatarUrl={pickParam(params.avatar_url)}
|
||||
target={pickParam(params.target)}
|
||||
character={pickParam(params.character)}
|
||||
mode={pickParam(params.mode)}
|
||||
promotionType={pickParam(params.promotion_type)}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import type { FeedbackCategory } from "@/data/schemas/feedback";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ const CATEGORY_OPTIONS: readonly CategoryOption[] = [
|
||||
|
||||
export function FeedbackScreen() {
|
||||
const navigator = useAppNavigator();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const form = useFeedbackSubmission();
|
||||
|
||||
if (form.feedbackId) {
|
||||
@@ -70,7 +71,7 @@ export function FeedbackScreen() {
|
||||
type="button"
|
||||
data-analytics-key="feedback.back_to_chat"
|
||||
className={styles.primaryButton}
|
||||
onClick={() => navigator.replace(ROUTES.chat)}
|
||||
onClick={() => navigator.replace(characterRoutes.chat)}
|
||||
>
|
||||
Back to Chat
|
||||
</button>
|
||||
@@ -93,7 +94,7 @@ export function FeedbackScreen() {
|
||||
|
||||
<header className={styles.header}>
|
||||
<BackButton
|
||||
href={ROUTES.chat}
|
||||
href={characterRoutes.chat}
|
||||
variant="soft"
|
||||
ariaLabel="Back to chat"
|
||||
analyticsKey="feedback.back_to_chat"
|
||||
|
||||
+3
-2
@@ -7,8 +7,9 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import { getCharacterRoutes } from "@/router/routes";
|
||||
|
||||
export default function Home() {
|
||||
redirect(ROUTES.splash);
|
||||
redirect(getCharacterRoutes(DEFAULT_CHARACTER_SLUG).splash);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { getCharacterRoutes } from "@/router/routes";
|
||||
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
|
||||
|
||||
@@ -79,7 +79,9 @@ describe("Private Room paywall navigation", () => {
|
||||
|
||||
renderHarness(root, "guest", roomDispatch);
|
||||
|
||||
expect(mocks.openAuth).toHaveBeenCalledWith(ROUTES.privateRoom);
|
||||
expect(mocks.openAuth).toHaveBeenCalledWith(
|
||||
getCharacterRoutes("elio").privateRoom,
|
||||
);
|
||||
expect(mocks.openSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
||||
|
||||
export default function PrivateRoomLayout({
|
||||
export default function LegacyPrivateRoomLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PrivateRoomRouteProvider>{children}</PrivateRoomRouteProvider>;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { PrivateRoomScreen } from "./private-room-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function PrivateRoomPage() {
|
||||
return <PrivateRoomScreen />;
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
appendRouteSearchParams,
|
||||
getCharacterRoutes,
|
||||
type RouteSearchParams,
|
||||
} from "@/router/routes";
|
||||
|
||||
export default async function PrivateRoomPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<RouteSearchParams>;
|
||||
}) {
|
||||
redirect(
|
||||
appendRouteSearchParams(
|
||||
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).privateRoom,
|
||||
await searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,20 +15,22 @@ export function getPrivateAlbumGalleryState(input: {
|
||||
export function buildPrivateAlbumGalleryUrl(
|
||||
albumId: string,
|
||||
imageIndex: number,
|
||||
): `/private-room?${string}` {
|
||||
basePath: string = ROUTES.privateRoom,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
[PRIVATE_ALBUM_QUERY_PARAM]: albumId,
|
||||
[PRIVATE_ALBUM_IMAGE_QUERY_PARAM]: String(imageIndex),
|
||||
});
|
||||
return `${ROUTES.privateRoom}?${params.toString()}` as const;
|
||||
return `${basePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildPrivateRoomWithoutGalleryUrl(input: {
|
||||
toString: () => string;
|
||||
}): string {
|
||||
export function buildPrivateRoomWithoutGalleryUrl(
|
||||
input: { toString: () => string },
|
||||
basePath: string = ROUTES.privateRoom,
|
||||
): string {
|
||||
const params = new URLSearchParams(input.toString());
|
||||
params.delete(PRIVATE_ALBUM_QUERY_PARAM);
|
||||
params.delete(PRIVATE_ALBUM_IMAGE_QUERY_PARAM);
|
||||
const query = params.toString();
|
||||
return query ? `${ROUTES.privateRoom}?${query}` : ROUTES.privateRoom;
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { CharacterAvatar } from "@/app/_components";
|
||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { isPrivateAlbumLocked } from "@/lib/private-room/private_album";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import {
|
||||
@@ -34,17 +37,12 @@ import {
|
||||
getPrivateAlbumGalleryState,
|
||||
} from "./private-album-gallery-url";
|
||||
|
||||
const FALLBACK_PROFILE = {
|
||||
name: "Elio Silvestri",
|
||||
avatar: "/images/avatar/elio.png",
|
||||
title: "Elio Private room",
|
||||
subtitle: "Join me, unlock my private room",
|
||||
} as const;
|
||||
|
||||
export function PrivateRoomScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const navigator = useAppNavigator();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const state = usePrivateRoomState();
|
||||
@@ -70,10 +68,10 @@ export function PrivateRoomScreen() {
|
||||
});
|
||||
usePrivateRoomUnlockSuccessRefresh(state.unlockSuccessNonce);
|
||||
|
||||
const displayName = FALLBACK_PROFILE.name;
|
||||
const avatarUrl = FALLBACK_PROFILE.avatar;
|
||||
const title = FALLBACK_PROFILE.title;
|
||||
const subtitle = FALLBACK_PROFILE.subtitle;
|
||||
const displayName = character.displayName;
|
||||
const avatarUrl = character.assets.avatar;
|
||||
const title = character.copy.privateRoomTitle;
|
||||
const subtitle = character.copy.privateRoomSubtitle;
|
||||
const pendingAlbum = useMemo(
|
||||
() =>
|
||||
state.items.find(
|
||||
@@ -98,15 +96,26 @@ export function PrivateRoomScreen() {
|
||||
isPrivateAlbumLocked(galleryAlbum) ||
|
||||
!image?.url
|
||||
) {
|
||||
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildPrivateRoomWithoutGalleryUrl(
|
||||
searchParams,
|
||||
characterRoutes.privateRoom,
|
||||
),
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
}, [galleryAlbum, galleryState, router, searchParams, state.isLoading]);
|
||||
}, [
|
||||
characterRoutes.privateRoom,
|
||||
galleryAlbum,
|
||||
galleryState,
|
||||
router,
|
||||
searchParams,
|
||||
state.isLoading,
|
||||
]);
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
if (isPrivateRoomAuthRequired(authState.loginStatus)) {
|
||||
navigator.openAuth(ROUTES.privateRoom);
|
||||
navigator.openAuth(characterRoutes.privateRoom);
|
||||
return;
|
||||
}
|
||||
navigator.openSubscription({
|
||||
@@ -121,7 +130,10 @@ export function PrivateRoomScreen() {
|
||||
|
||||
const handleOpenGallery = (albumId: string) => {
|
||||
openedGalleryInPageRef.current = true;
|
||||
router.push(buildPrivateAlbumGalleryUrl(albumId, 0), { scroll: false });
|
||||
router.push(
|
||||
buildPrivateAlbumGalleryUrl(albumId, 0, characterRoutes.privateRoom),
|
||||
{ scroll: false },
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloseGallery = () => {
|
||||
@@ -130,15 +142,23 @@ export function PrivateRoomScreen() {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace(buildPrivateRoomWithoutGalleryUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
router.replace(
|
||||
buildPrivateRoomWithoutGalleryUrl(
|
||||
searchParams,
|
||||
characterRoutes.privateRoom,
|
||||
),
|
||||
{ scroll: false },
|
||||
);
|
||||
};
|
||||
|
||||
const handleGalleryIndexChange = (imageIndex: number) => {
|
||||
if (!galleryAlbum) return;
|
||||
router.replace(
|
||||
buildPrivateAlbumGalleryUrl(galleryAlbum.albumId, imageIndex),
|
||||
buildPrivateAlbumGalleryUrl(
|
||||
galleryAlbum.albumId,
|
||||
imageIndex,
|
||||
characterRoutes.privateRoom,
|
||||
),
|
||||
{ scroll: false },
|
||||
);
|
||||
};
|
||||
@@ -152,7 +172,7 @@ export function PrivateRoomScreen() {
|
||||
<section className={styles.hero} aria-label={title}>
|
||||
<div className={styles.heroCover}>
|
||||
<Image
|
||||
src="/images/private-room/banner/elio.png"
|
||||
src={character.assets.privateRoomBanner}
|
||||
alt=""
|
||||
width={2048}
|
||||
height={1152}
|
||||
@@ -241,7 +261,10 @@ export function PrivateRoomScreen() {
|
||||
|
||||
<AppBottomNav
|
||||
activeItem="privateRoom"
|
||||
onChatClick={() => navigator.push(ROUTES.splash, { scroll: false })}
|
||||
privateRoomLabel={character.copy.privateRoomTitle}
|
||||
onChatClick={() =>
|
||||
navigator.push(characterRoutes.splash, { scroll: false })
|
||||
}
|
||||
onPrivateRoomClick={() => undefined}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Dispatch, useEffect, useRef } from "react";
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import type { AuthEvent } from "@/stores/auth/auth-events";
|
||||
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||
@@ -78,12 +78,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
|
||||
unlockPaywallRequest,
|
||||
}: UsePrivateRoomUnlockPaywallNavigationInput): void {
|
||||
const navigator = useAppNavigator();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!unlockPaywallRequest) return;
|
||||
|
||||
if (isPrivateRoomAuthRequired(loginStatus)) {
|
||||
navigator.openAuth(ROUTES.privateRoom);
|
||||
navigator.openAuth(characterRoutes.privateRoom);
|
||||
} else {
|
||||
behaviorAnalytics.paywallShown({
|
||||
entryPoint: "private_album_unlock",
|
||||
@@ -99,7 +100,13 @@ export function usePrivateRoomUnlockPaywallNavigation({
|
||||
});
|
||||
}
|
||||
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
|
||||
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
|
||||
}, [
|
||||
characterRoutes.privateRoom,
|
||||
loginStatus,
|
||||
navigator,
|
||||
roomDispatch,
|
||||
unlockPaywallRequest,
|
||||
]);
|
||||
}
|
||||
|
||||
export function usePrivateRoomUnlockSuccessRefresh(
|
||||
|
||||
@@ -5,8 +5,8 @@ import { signOut } from "next-auth/react";
|
||||
|
||||
import { BackButton } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useActiveCharacterRoutes } from "@/providers/character-provider";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||
|
||||
@@ -19,6 +19,7 @@ import styles from "./components/sidebar-screen.module.css";
|
||||
|
||||
export function SidebarScreen() {
|
||||
const navigator = useAppNavigator();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const user = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
const auth = useAuthState();
|
||||
@@ -31,7 +32,7 @@ export function SidebarScreen() {
|
||||
userDispatch({ type: "UserClearLocal" });
|
||||
authDispatch({ type: "AuthLogoutSubmitted" });
|
||||
void signOut({ redirect: false });
|
||||
navigator.replace(ROUTES.splash, { scroll: false });
|
||||
navigator.replace(characterRoutes.splash, { scroll: false });
|
||||
};
|
||||
|
||||
const view = getSidebarViewModel({
|
||||
@@ -47,7 +48,7 @@ export function SidebarScreen() {
|
||||
|
||||
<div className={styles.topBar}>
|
||||
<BackButton
|
||||
href={ROUTES.chat}
|
||||
href={characterRoutes.chat}
|
||||
variant="soft"
|
||||
analyticsKey="sidebar.back_to_chat"
|
||||
/>
|
||||
@@ -59,7 +60,7 @@ export function SidebarScreen() {
|
||||
name={view.name}
|
||||
avatarUrl={view.avatarUrl}
|
||||
isLoading={view.isInitializingUser}
|
||||
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
||||
onLoginClick={() => navigator.openAuth(characterRoutes.sidebar)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -91,7 +92,7 @@ export function SidebarScreen() {
|
||||
},
|
||||
})
|
||||
}
|
||||
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
||||
onRulesClick={() => navigator.push(characterRoutes.coinsRules)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -104,7 +105,7 @@ export function SidebarScreen() {
|
||||
data-analytics-key="sidebar.open_feedback"
|
||||
data-analytics-label="Open feedback"
|
||||
className={`${styles.logoutCard} ${styles.feedbackCard}`}
|
||||
onClick={() => navigator.push(ROUTES.feedback)}
|
||||
onClick={() => navigator.push(characterRoutes.feedback)}
|
||||
>
|
||||
<span
|
||||
className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
*
|
||||
*
|
||||
* `SizedBox.expand(child: Assets.images.picBgHome.image(fit: BoxFit.cover))`
|
||||
* 资源: /public/images/cover/elio.png
|
||||
* 原名: pic_bg_home.png (snake_case)
|
||||
*
|
||||
* sizes 属性说明:
|
||||
* 图片在 MobileShell 内(max-width: 540px)。
|
||||
* - 视口 ≤ 540px:MobileShell = 100vw,图片 = 100vw
|
||||
@@ -13,11 +10,15 @@
|
||||
*/
|
||||
import Image from "next/image";
|
||||
|
||||
export function SplashBackground() {
|
||||
export function SplashBackground({
|
||||
src = "/images/cover/elio.png",
|
||||
}: {
|
||||
src?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
|
||||
<Image
|
||||
src="/images/cover/elio.png"
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
|
||||
@@ -8,12 +8,16 @@ import styles from "./splash-latest-message.module.css";
|
||||
|
||||
export interface SplashLatestMessageProps {
|
||||
message: string | null;
|
||||
characterName: string;
|
||||
avatarUrl: string;
|
||||
isLoading?: boolean;
|
||||
onOpenChat: () => void;
|
||||
}
|
||||
|
||||
export function SplashLatestMessage({
|
||||
message,
|
||||
characterName,
|
||||
avatarUrl,
|
||||
isLoading = false,
|
||||
onOpenChat,
|
||||
}: SplashLatestMessageProps) {
|
||||
@@ -26,10 +30,11 @@ export function SplashLatestMessage({
|
||||
data-analytics-label="Open latest message"
|
||||
className={styles.card}
|
||||
onClick={onOpenChat}
|
||||
aria-label="Open latest message from Elio"
|
||||
aria-label={`Open latest message from ${characterName}`}
|
||||
>
|
||||
<span className={styles.avatarWrap}>
|
||||
<CharacterAvatar
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
size="100%"
|
||||
imageSize={42}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { LoginStatus } from "@/data/schemas/auth";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
|
||||
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
||||
@@ -12,6 +13,7 @@ import { Result } from "@/utils/result";
|
||||
const log = new Logger("SplashLatestMessage");
|
||||
|
||||
export interface UseSplashLatestMessageInput {
|
||||
characterId?: string;
|
||||
hasInitialized: boolean;
|
||||
isAuthLoading: boolean;
|
||||
loginStatus: LoginStatus;
|
||||
@@ -29,6 +31,7 @@ interface SplashLatestMessageState {
|
||||
}
|
||||
|
||||
export function useSplashLatestMessage({
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
hasInitialized,
|
||||
isAuthLoading,
|
||||
loginStatus,
|
||||
@@ -45,7 +48,7 @@ export function useSplashLatestMessage({
|
||||
hasInitialized &&
|
||||
!isAuthLoading &&
|
||||
loginStatus !== "notLoggedIn";
|
||||
const requestKey = shouldLoad ? loginStatus : "";
|
||||
const requestKey = shouldLoad ? `${loginStatus}:${characterId}` : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad) return;
|
||||
@@ -53,7 +56,10 @@ export function useSplashLatestMessage({
|
||||
let cancelled = false;
|
||||
|
||||
const loadLatestMessage = async () => {
|
||||
const result = await loadSplashLatestMessagePreview({ cache });
|
||||
const result = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
if (Result.isErr(result)) {
|
||||
@@ -77,7 +83,7 @@ export function useSplashLatestMessage({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cache, requestKey, shouldLoad]);
|
||||
}, [cache, characterId, requestKey, shouldLoad]);
|
||||
|
||||
if (!shouldLoad) return { message: null, isLoading: false };
|
||||
if (state.key !== requestKey || !state.loaded) {
|
||||
|
||||
+19
-3
@@ -1,5 +1,21 @@
|
||||
import { SplashScreen } from "@/app/splash/splash-screen";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SplashPage() {
|
||||
return <SplashScreen />;
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
appendRouteSearchParams,
|
||||
getCharacterRoutes,
|
||||
type RouteSearchParams,
|
||||
} from "@/router/routes";
|
||||
|
||||
export default async function SplashPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<RouteSearchParams>;
|
||||
}) {
|
||||
redirect(
|
||||
appendRouteSearchParams(
|
||||
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).splash,
|
||||
await searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { pwaUtil } from "@/utils/pwa";
|
||||
|
||||
@@ -20,8 +23,11 @@ import styles from "./components/splash-screen.module.css";
|
||||
|
||||
export function SplashScreen() {
|
||||
const navigator = useAppNavigator();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const latestMessage = useSplashLatestMessage({
|
||||
characterId: character.id,
|
||||
hasInitialized: authState.hasInitialized,
|
||||
isAuthLoading: authState.isLoading,
|
||||
loginStatus: authState.loginStatus,
|
||||
@@ -32,11 +38,11 @@ export function SplashScreen() {
|
||||
};
|
||||
|
||||
const handleOpenPrivateRoom = () => {
|
||||
navigator.push(ROUTES.privateRoom, { scroll: false });
|
||||
navigator.push(characterRoutes.privateRoom, { scroll: false });
|
||||
};
|
||||
|
||||
const handleOpenSplash = () => {
|
||||
navigator.push(ROUTES.splash, { scroll: false });
|
||||
navigator.push(characterRoutes.splash, { scroll: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,7 +52,7 @@ export function SplashScreen() {
|
||||
return (
|
||||
<MobileShell background="var(--color-sidebar-background)">
|
||||
<div className={styles.wrapper}>
|
||||
<SplashBackground />
|
||||
<SplashBackground src={character.assets.cover} />
|
||||
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
|
||||
<div className={styles.gradientOverlay} aria-hidden="true" />
|
||||
<div className={styles.content}>
|
||||
@@ -54,6 +60,8 @@ export function SplashScreen() {
|
||||
<div className={styles.spacer} />
|
||||
<SplashLatestMessage
|
||||
message={latestMessage.message}
|
||||
characterName={character.shortName}
|
||||
avatarUrl={character.assets.avatar}
|
||||
isLoading={latestMessage.isLoading}
|
||||
onOpenChat={handleStartChat}
|
||||
/>
|
||||
@@ -62,7 +70,7 @@ export function SplashScreen() {
|
||||
<SplashButton onStartChat={handleStartChat} />
|
||||
</div>
|
||||
<p className={styles.bottom}>
|
||||
Elio Silvestri, Your exclusive AI boyfriend
|
||||
{character.displayName}, {character.copy.splashRelationship}
|
||||
<br />
|
||||
24/7 online | Chat | Companion | Heal | Sweet moments
|
||||
</p>
|
||||
@@ -70,6 +78,7 @@ export function SplashScreen() {
|
||||
<AppBottomNav
|
||||
activeItem="chat"
|
||||
variant="dark"
|
||||
privateRoomLabel={character.copy.privateRoomTitle}
|
||||
onChatClick={handleOpenSplash}
|
||||
onPrivateRoomClick={handleOpenPrivateRoom}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -22,12 +23,14 @@ export interface SubscriptionCheckoutButtonProps {
|
||||
disabled?: boolean;
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
characterSlug?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
disabled = false,
|
||||
subscriptionType,
|
||||
returnTo = null,
|
||||
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
@@ -37,6 +40,7 @@ export function SubscriptionCheckoutButton({
|
||||
payment,
|
||||
paymentDispatch,
|
||||
returnTo: returnTo ?? undefined,
|
||||
characterSlug,
|
||||
subscriptionType,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
parseSubscriptionReturnTo,
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
DEFAULT_CHARACTER_SLUG,
|
||||
getCharacterBySlug,
|
||||
} from "@/data/constants/character";
|
||||
|
||||
import {
|
||||
SubscriptionScreen,
|
||||
@@ -25,6 +29,9 @@ export default async function SubscriptionPage({
|
||||
const subscriptionType = toSubscriptionType(
|
||||
getFirstPaymentSearchParam(query.type),
|
||||
);
|
||||
const characterSlug =
|
||||
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
|
||||
DEFAULT_CHARACTER_SLUG;
|
||||
const analyticsContext = parsePaymentAnalyticsContext({
|
||||
entryPoint: getFirstPaymentSearchParam(
|
||||
query[PAYMENT_ANALYTICS_ENTRY_PARAM],
|
||||
@@ -42,6 +49,7 @@ export default async function SubscriptionPage({
|
||||
returnTo={parseSubscriptionReturnTo(query.returnTo)}
|
||||
initialPayChannel={paymentReturn.initialPayChannel}
|
||||
analyticsContext={analyticsContext}
|
||||
characterSlug={characterSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics
|
||||
import { AppConstants } from "@/core/app_constants";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
behaviorAnalytics,
|
||||
getDefaultPaymentAnalyticsContext,
|
||||
@@ -42,6 +43,7 @@ export interface SubscriptionScreenProps {
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
initialPayChannel?: PayChannel | null;
|
||||
analyticsContext?: PaymentAnalyticsContext;
|
||||
characterSlug?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionScreen({
|
||||
@@ -50,6 +52,7 @@ export function SubscriptionScreen({
|
||||
returnTo = null,
|
||||
initialPayChannel = null,
|
||||
analyticsContext: providedAnalyticsContext,
|
||||
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: SubscriptionScreenProps) {
|
||||
const userState = useUserState();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
@@ -70,6 +73,7 @@ export function SubscriptionScreen({
|
||||
subscriptionType,
|
||||
shouldResumePendingOrder,
|
||||
returnTo,
|
||||
characterSlug,
|
||||
initialPayChannel: resolvedInitialPayChannel,
|
||||
});
|
||||
const canSubscribeVip = subscriptionType === "vip";
|
||||
@@ -260,6 +264,7 @@ export function SubscriptionScreen({
|
||||
disabled={!canActivate}
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
characterSlug={characterSlug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import type { PayChannel } from "@/data/schemas/payment";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
|
||||
import type { SubscriptionType } from "./subscription-screen.helpers";
|
||||
@@ -14,6 +15,7 @@ export interface UseSubscriptionPaymentFlowInput {
|
||||
shouldResumePendingOrder: boolean;
|
||||
returnTo: SubscriptionReturnTo;
|
||||
initialPayChannel: PayChannel;
|
||||
characterSlug?: string;
|
||||
}
|
||||
|
||||
export function useSubscriptionPaymentFlow({
|
||||
@@ -21,6 +23,7 @@ export function useSubscriptionPaymentFlow({
|
||||
shouldResumePendingOrder,
|
||||
returnTo,
|
||||
initialPayChannel,
|
||||
characterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: UseSubscriptionPaymentFlowInput) {
|
||||
const navigator = useAppNavigator();
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
@@ -41,13 +44,13 @@ export function useSubscriptionPaymentFlow({
|
||||
}, [payment.currentOrderId, payment.isPaid]);
|
||||
|
||||
const handleBackClick = () => {
|
||||
navigator.exitSubscription(returnTo);
|
||||
navigator.exitSubscription(returnTo, characterSlug);
|
||||
};
|
||||
|
||||
const handlePaymentSuccessClose = () => {
|
||||
setShowPaymentSuccessDialog(false);
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
navigator.exitSubscriptionAfterSuccess(returnTo);
|
||||
navigator.exitSubscriptionAfterSuccess(returnTo, characterSlug);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||
|
||||
export default function TipLayout({ children }: { children: ReactNode }) {
|
||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
||||
export default function LegacyTipLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
|
||||
+13
-26
@@ -1,34 +1,21 @@
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentReturnSearchParams,
|
||||
type PaymentSearchParams,
|
||||
} from "@/lib/payment/payment_search_params";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
resolveTipCoffeeType,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { TipScreen } from "./tip-screen";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import {
|
||||
appendRouteSearchParams,
|
||||
getCharacterRoutes,
|
||||
type RouteSearchParams,
|
||||
} from "@/router/routes";
|
||||
|
||||
export default async function TipPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<PaymentSearchParams>;
|
||||
searchParams: Promise<RouteSearchParams>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const paymentReturn = parsePaymentReturnSearchParams(query);
|
||||
const coffeeType =
|
||||
resolveTipCoffeeType(
|
||||
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
|
||||
) ??
|
||||
DEFAULT_TIP_COFFEE_TYPE;
|
||||
|
||||
return (
|
||||
<TipScreen
|
||||
coffeeType={coffeeType}
|
||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||
initialPayChannel={paymentReturn.initialPayChannel}
|
||||
/>
|
||||
redirect(
|
||||
appendRouteSearchParams(
|
||||
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).tip,
|
||||
await searchParams,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import type { TipCoffeeType } from "@/lib/tip/tip_coffee";
|
||||
import { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
@@ -28,6 +29,7 @@ export function TipCheckoutButton({
|
||||
onOrder,
|
||||
returnPath,
|
||||
}: TipCheckoutButtonProps) {
|
||||
const character = useActiveCharacter();
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const paymentLaunch = usePaymentLaunchFlow({
|
||||
@@ -37,6 +39,7 @@ export function TipCheckoutButton({
|
||||
paymentDispatch,
|
||||
subscriptionType: "tip",
|
||||
tipCoffeeType: coffeeType,
|
||||
characterSlug: character.slug,
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
height: 330px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 242, 232, 0.3), #fff2e8 96%),
|
||||
url("/images/cover/elio.png") center 18% / cover no-repeat;
|
||||
var(--tip-cover-image) center 18% / cover no-repeat;
|
||||
opacity: 0.36;
|
||||
filter: saturate(0.95) blur(0.2px);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
useActiveCharacterRoutes,
|
||||
} from "@/providers/character-provider";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { TipCheckoutButton } from "./tip-checkout-button";
|
||||
@@ -52,11 +56,16 @@ export function TipScreen({
|
||||
initialPayChannel = null,
|
||||
}: TipScreenProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const authState = useAuthState();
|
||||
const [selectedCoffeeType, setSelectedCoffeeType] =
|
||||
useState<TipCoffeeType>(coffeeType);
|
||||
const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
|
||||
const returnPath = buildTipCoffeePath(selectedCoffeeType);
|
||||
const returnPath = buildTipCoffeePath(
|
||||
selectedCoffeeType,
|
||||
characterRoutes.tip,
|
||||
);
|
||||
const resolvedInitialPayChannel =
|
||||
initialPayChannel ?? navigator.getDefaultPayChannel();
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
@@ -155,7 +164,10 @@ export function TipScreen({
|
||||
}
|
||||
if (!canCreateOrder) return;
|
||||
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
paymentDispatch({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: character.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCoffeeTypeChange = (type: TipCoffeeType) => {
|
||||
@@ -182,7 +194,14 @@ export function TipScreen({
|
||||
|
||||
return (
|
||||
<MobileShell background="#fff5ed">
|
||||
<main className={styles.shell}>
|
||||
<main
|
||||
className={styles.shell}
|
||||
style={
|
||||
{
|
||||
"--tip-cover-image": `url("${character.assets.cover}")`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.bgImage} aria-hidden="true" />
|
||||
<div className={styles.bgGlowOne} aria-hidden="true" />
|
||||
<div className={styles.bgGlowTwo} aria-hidden="true" />
|
||||
@@ -198,13 +217,14 @@ export function TipScreen({
|
||||
>
|
||||
<ArrowLeft size={19} aria-hidden="true" />
|
||||
</button>
|
||||
<span className={styles.headerPill}>Tip Elio</span>
|
||||
<span className={styles.headerPill}>{character.copy.tipHeader}</span>
|
||||
</header>
|
||||
|
||||
<section className={styles.hero} aria-labelledby="tip-title">
|
||||
<div className={styles.avatarRing}>
|
||||
<CharacterAvatar
|
||||
alt="Elio Silvestri"
|
||||
src={character.assets.avatar}
|
||||
alt={character.displayName}
|
||||
size="100%"
|
||||
imageSize={88}
|
||||
priority
|
||||
@@ -212,7 +232,7 @@ export function TipScreen({
|
||||
</div>
|
||||
<p className={styles.eyebrow}>A little sweetness for today</p>
|
||||
<h1 id="tip-title" className={styles.title}>
|
||||
Buy Elio a coffee
|
||||
{character.copy.tipTitle}
|
||||
</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Send a warm coffee tip and keep the private moments glowing.
|
||||
|
||||
Reference in New Issue
Block a user