Merge pull request #62 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-10-09 02:16:39 +02:00 committed by GitHub
commit 550b3fc98c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 890 additions and 12 deletions

View File

@ -1,6 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
// Parse API URL to get hostname and port for images configuration
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4242";
const apiUrlObj = new URL(apiUrl);
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
@ -19,6 +23,12 @@ const nextConfig: NextConfig = {
hostname: "assets.witlab.us",
pathname: "/**",
},
{
protocol: apiUrlObj.protocol.replace(":", "") as "http" | "https",
hostname: apiUrlObj.hostname,
...(apiUrlObj.port && { port: apiUrlObj.port }),
pathname: "/**",
},
],
},
};

View File

@ -5,27 +5,33 @@ import {
AdvisersSectionSkeleton,
CompatibilitySection,
CompatibilitySectionSkeleton,
HoroscopeSection,
MeditationSection,
MeditationSectionSkeleton,
PalmSection,
PalmSectionSkeleton,
PortraitsSection,
} from "@/components/domains/dashboard";
import { Horoscope } from "@/components/widgets";
import { loadChatsList } from "@/entities/chats/loaders";
import {
loadAssistants,
loadCompatibility,
loadMeditations,
loadPalms,
loadPortraits,
} from "@/entities/dashboard/loaders";
import styles from "./page.module.scss";
export default function Home() {
export default async function Home() {
const chatsPromise = loadChatsList();
const portraits = await loadPortraits();
return (
<section className={styles.page}>
<Horoscope />
<PortraitsSection portraits={portraits} />
<HoroscopeSection />
<Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection

View File

@ -0,0 +1,7 @@
export default function PortraitLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@ -0,0 +1,28 @@
import { notFound } from "next/navigation";
import { PortraitView } from "@/components/domains/portraits";
import { getDashboard } from "@/entities/dashboard/api";
export default async function PortraitPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Get portrait data from dashboard
const dashboard = await getDashboard();
const portrait = dashboard.partnerPortraits?.find(p => p._id === id);
if (!portrait || portrait.status !== "done" || !portrait.imageUrl) {
notFound();
}
return (
<PortraitView
id={portrait._id}
title={portrait.title}
imageUrl={portrait.imageUrl}
/>
);
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
interface GenerationStatusResponse {
id: string;
status: "queued" | "processing" | "done" | "error";
result?: string | null;
createdAt?: string;
finishedAt?: string | null;
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// This runs on the server and can access HttpOnly cookies
const response = await http.get<GenerationStatusResponse>(
API_ROUTES.statusGeneration(id),
{
cache: "no-store", // Don't cache status checks
}
);
// Generate imageUrl if status is done
let imageUrl: string | null = null;
if (response.status === "done") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
imageUrl = `${apiUrl}/partner-portrait/${id}/image`;
}
return NextResponse.json({
id: response.id,
status: response.status,
imageUrl,
});
} catch {
// Return error status without breaking
return NextResponse.json(
{ error: "Failed to fetch status" },
{ status: 500 }
);
}
}

View File

@ -51,7 +51,7 @@ export default function RefillOptionsHeader({
{t("title", { name: currentChat?.assistantName || "" })}
</Typography>
<Typography as="p" size="sm" align="left" className={styles.subtitle}>
{t("subtitle", { name: user?.profile.name || "" })}
{t("subtitle", { name: user?.profile?.name || "" })}
</Typography>
</div>
<div className={styles.timer}>

View File

@ -17,14 +17,15 @@ export default function GlobalNewMessagesBanner() {
const { unreadChats } = useChats();
const { balance } = useBalance();
// Exclude banner on chat-related, settings (profile), and retention funnel pages
// Exclude banner on chat-related, settings (profile), retention funnel, and portraits pages
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
const isExcluded =
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
pathnameWithoutLocale.startsWith("/retaining");
pathnameWithoutLocale.startsWith("/retaining") ||
pathnameWithoutLocale.startsWith("/portraits");
const hasHydrated = useAppUiStore(state => state._hasHydrated);
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);

View File

@ -0,0 +1,175 @@
.card.card {
padding: 0;
min-width: 220px;
max-width: 220px;
height: auto;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&.disabled {
cursor: default;
&:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
}
.imageContainer {
position: relative;
width: 100%;
height: 240px;
background: linear-gradient(180deg, #f5f5f5 0%, #e0e0e0 100%);
overflow: hidden;
}
.portraitImage {
object-fit: cover;
object-position: center;
}
.placeholderImage {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
}
.placeholderLoader {
color: #999;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: white;
}
.textContent {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
text-align: left;
}
.title {
line-height: 1.3;
text-align: left;
}
.subtitle {
line-height: 1.4;
text-align: left;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
}
.statusDone {
color: #16A34A;
.statusText {
color: #16A34A;
}
.checkmark {
color: #16A34A;
}
}
.statusProcessing {
color: #3b82f6;
svg {
animation: spin 1s linear infinite;
}
}
.statusQueued {
color: #f59e0b;
svg {
color: #f59e0b;
}
}
.statusError {
color: #ef4444;
svg {
color: #ef4444;
}
}
.iconCheck,
.iconQueued,
.iconError {
flex-shrink: 0;
}
.iconProcessing {
flex-shrink: 0;
animation: spin 1s linear infinite;
}
.actionButton {
width: 40px;
height: 40px;
border-radius: 50%;
background: #3b82f6;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
flex-shrink: 0;
svg {
color: white;
}
&:hover {
background: #2563eb;
}
&:active {
transform: scale(0.95);
}
}

View File

@ -0,0 +1,123 @@
"use client";
import Image from "next/image";
import { Card, Icon, IconName, Typography } from "@/components/ui";
import { PartnerPortrait } from "@/entities/dashboard/types";
import { useGenerationStatus } from "@/hooks/generations/useGenerationStatus";
import styles from "./PortraitCard.module.scss";
type PortraitCardProps = PartnerPortrait;
const HeartCheckIcon = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_2443_1886)">
<path d="M1.78594 8.96384L6.72695 13.5767C6.93203 13.7681 7.20273 13.8748 7.48438 13.8748C7.76602 13.8748 8.03672 13.7681 8.2418 13.5767L13.1828 8.96384C14.0141 8.19001 14.4844 7.10447 14.4844 5.9697V5.81111C14.4844 3.89978 13.1035 2.27009 11.2195 1.95564C9.97266 1.74783 8.70391 2.15525 7.8125 3.04666L7.48438 3.37478L7.15625 3.04666C6.26484 2.15525 4.99609 1.74783 3.74922 1.95564C1.86523 2.27009 0.484375 3.89978 0.484375 5.81111V5.9697C0.484375 7.10447 0.954687 8.19001 1.78594 8.96384Z" fill="#16A34A"/>
</g>
<defs>
<clipPath id="clip0_2443_1886">
<path d="M0.484375 0.75H14.4844V14.75H0.484375V0.75Z" fill="white"/>
</clipPath>
</defs>
</svg>
);
const getStatusConfig = (status: PartnerPortrait["status"]) => {
switch (status) {
case "done":
return {
icon: <HeartCheckIcon />,
text: "Ready",
showCheckmark: true,
className: styles.statusDone,
};
case "processing":
return {
icon: <Icon name={IconName.Loader} className={styles.iconProcessing} size={{ width: 16, height: 16 }} />,
text: "Generating...",
showCheckmark: false,
className: styles.statusProcessing,
};
case "queued":
return {
icon: <Icon name={IconName.Clock} className={styles.iconQueued} size={{ width: 16, height: 16 }} />,
text: "In Queue",
showCheckmark: false,
className: styles.statusQueued,
};
case "error":
return {
icon: <Icon name={IconName.AlertCircle} className={styles.iconError} size={{ width: 16, height: 16 }} />,
text: "Error",
showCheckmark: false,
className: styles.statusError,
};
}
};
export default function PortraitCard({
_id,
title,
status: initialStatus,
imageUrl: initialImageUrl,
}: PortraitCardProps) {
// Use polling hook to update status in real-time
const { status, imageUrl: polledImageUrl } = useGenerationStatus(_id, initialStatus);
// Use polled imageUrl if available, otherwise use initial
const imageUrl = polledImageUrl || initialImageUrl;
const statusConfig = getStatusConfig(status as PartnerPortrait["status"]);
const isReady = status === "done" && imageUrl;
return (
<Card className={`${styles.card} ${!isReady ? styles.disabled : ""}`}>
<div className={styles.imageContainer}>
{imageUrl ? (
<Image
className={styles.portraitImage}
src={imageUrl}
alt={title}
fill
sizes="(max-width: 768px) 100vw, 300px"
style={{ objectFit: "cover" }}
/>
) : (
<div className={styles.placeholderImage}>
<Icon name={IconName.Loader} className={styles.placeholderLoader} size={{ width: 48, height: 48 }} />
</div>
)}
</div>
<div className={styles.content}>
<div className={styles.textContent}>
<Typography as="h3" size="lg" weight="semiBold" align="left" className={styles.title}>
{title}
</Typography>
<Typography size="sm" color="secondary" align="left" className={styles.subtitle}>
Finding the One Guide
</Typography>
</div>
<div className={styles.footer}>
<div className={`${styles.status} ${statusConfig.className}`}>
{statusConfig.icon}
<Typography size="sm" weight="medium" className={styles.statusText}>
{statusConfig.text}
</Typography>
{statusConfig.showCheckmark && (
<Icon name={IconName.Check} className={styles.checkmark} size={{ width: 14, height: 14 }} />
)}
</div>
{status === "done" && (
<button className={styles.actionButton} aria-label="View portrait">
<Icon name={IconName.ChevronRight} size={{ width: 20, height: 20 }} color="white" />
</button>
)}
</div>
</div>
</Card>
);
}

View File

@ -2,3 +2,4 @@ export { default as AdviserCard } from "./AdviserCard/AdviserCard";
export { default as CompatibilityCard } from "./CompatibilityCard/CompatibilityCard";
export { default as MeditationCard } from "./MeditationCard/MeditationCard";
export { default as PalmCard } from "./PalmCard/PalmCard";
export { default as PortraitCard } from "./PortraitCard/PortraitCard";

View File

@ -0,0 +1,5 @@
.sectionContent {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -0,0 +1,12 @@
import { Section } from "@/components/ui";
import { Horoscope } from "@/components/widgets";
import styles from "./HoroscopeSection.module.scss";
export default function HoroscopeSection() {
return (
<Section title="Horoscope" contentClassName={styles.sectionContent}>
<Horoscope />
</Section>
);
}

View File

@ -0,0 +1,30 @@
.sectionContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.grid.grid {
display: flex;
overflow-x: auto;
gap: 16px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 8px 0 12px 0;
margin: -8px 0 -12px 0;
&::-webkit-scrollbar {
display: none;
}
> * {
scroll-snap-align: start;
flex-shrink: 0;
}
}
.disabledLink {
pointer-events: none;
cursor: default;
}

View File

@ -0,0 +1,34 @@
import Link from "next/link";
import { Grid, Section } from "@/components/ui";
import { PartnerPortrait } from "@/entities/dashboard/types";
import { PortraitCard } from "../../cards";
import styles from "./PortraitsSection.module.scss";
interface PortraitsSectionProps {
portraits: PartnerPortrait[];
}
export default function PortraitsSection({ portraits }: PortraitsSectionProps) {
if (!portraits || portraits.length === 0) {
return null;
}
return (
<Section title="Portraits" contentClassName={styles.sectionContent}>
<Grid columns={portraits.length} className={styles.grid}>
{portraits.map(portrait => (
<Link
href={portrait.status === "done" && portrait.imageUrl ? `/portraits/${portrait._id}` : "#"}
key={portrait._id}
className={portrait.status === "done" && portrait.imageUrl ? "" : styles.disabledLink}
>
<PortraitCard {...portrait} />
</Link>
))}
</Grid>
</Section>
);
}

View File

@ -6,6 +6,7 @@ export {
default as CompatibilitySection,
CompatibilitySectionSkeleton,
} from "./CompatibilitySection/CompatibilitySection";
export { default as HoroscopeSection } from "./HoroscopeSection/HoroscopeSection";
export {
default as MeditationSection,
MeditationSectionSkeleton,
@ -18,3 +19,4 @@ export {
default as PalmSection,
PalmSectionSkeleton,
} from "./PalmSection/PalmSection";
export { default as PortraitsSection } from "./PortraitsSection/PortraitsSection";

View File

@ -0,0 +1,108 @@
.container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--background);
position: relative;
flex-shrink: 0;
}
.backButton {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s ease;
&:hover {
background: #e0e0e0;
}
&:active {
background: #d0d0d0;
}
}
.title {
flex: 1;
text-align: center;
padding-right: 40px; // Compensate for back button width
}
.imageWrapper {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 24px 24px 24px;
overflow-y: auto;
}
.imageContainer {
position: relative;
width: 100%;
max-width: 500px;
aspect-ratio: 3 / 4;
border-radius: 24px;
overflow: visible;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.imageInner {
position: relative;
width: 100%;
height: 100%;
border-radius: 24px;
overflow: hidden;
}
.image {
object-fit: cover;
object-position: center;
}
.downloadButton {
position: absolute;
right: 8px;
bottom: 8px;
width: 52px;
height: 52px;
background: none;
border: none;
cursor: pointer;
padding: 0;
z-index: 10;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
svg {
width: 100%;
height: 100%;
display: block;
}
}

View File

@ -0,0 +1,99 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Icon, IconName, Typography } from "@/components/ui";
import styles from "./PortraitView.module.scss";
interface PortraitViewProps {
id: string;
title: string;
imageUrl: string;
}
export default function PortraitView({ title, imageUrl }: PortraitViewProps) {
const router = useRouter();
const handleDownload = async () => {
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title.replace(/\s+/g, "_")}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch {
// Fallback: open in new tab if download fails
window.open(imageUrl, "_blank");
}
};
return (
<div className={styles.container}>
{/* Header with back button and title */}
<div className={styles.header}>
<button className={styles.backButton} onClick={() => router.back()}>
<Icon name={IconName.ChevronLeft} size={{ width: 24, height: 24 }} />
</button>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{title}
</Typography>
</div>
{/* Portrait Image */}
<div className={styles.imageWrapper}>
<div className={styles.imageContainer}>
<div className={styles.imageInner}>
<Image
src={imageUrl}
alt="Partner Portrait"
fill
className={styles.image}
sizes="(max-width: 768px) 90vw, 600px"
priority
/>
</div>
{/* Download Button */}
<button
className={styles.downloadButton}
onClick={handleDownload}
aria-label="Download portrait"
>
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_2449_3797)">
<path d="M6 22C6 10.9543 14.9543 2 26 2C37.0457 2 46 10.9543 46 22C46 33.0457 37.0457 42 26 42C14.9543 42 6 33.0457 6 22Z" fill="white" fillOpacity="0.75"/>
<path d="M25 13.0124C24.9983 12.4601 25.4446 12.011 25.9969 12.0093C26.5492 12.0076 26.9983 12.4539 27 13.0061L25 13.0124Z" fill="#646464"/>
<path d="M28.3158 20.2952L27.0269 21.5921L27 13.0063L25 13.0126L25.0269 21.5984L23.7301 20.3096C23.3383 19.9203 22.7051 19.9222 22.3158 20.314C21.9265 20.7057 21.9285 21.3389 22.3203 21.7282L22.3228 21.7307L22.3238 21.7317L26.039 25.4237L29.7206 21.7188L29.7262 21.7132L29.727 21.7124L29.7278 21.7116L29.7337 21.7057L28.3158 20.2952Z" fill="#646464"/>
<path d="M29.7345 21.7049C30.1238 21.3131 30.1218 20.6799 29.7301 20.2906C29.3383 19.9014 28.7051 19.9034 28.3159 20.2951L29.7345 21.7049Z" fill="#646464"/>
<path d="M18 22C18 20.8954 18.8954 20 20 20C20.5523 20 21 19.5523 21 19C21 18.4477 20.5523 18 20 18C17.7909 18 16 19.7909 16 22V28C16 30.2091 17.7909 32 20 32H31C33.7614 32 36 29.7614 36 27V22C36 19.7909 34.2091 18 32 18C31.4477 18 31 18.4477 31 19C31 19.5523 31.4477 20 32 20C33.1046 20 34 20.8954 34 22V27C34 28.6569 32.6569 30 31 30H20C18.8954 30 18 29.1046 18 28V22Z" fill="#646464"/>
</g>
<defs>
<filter id="filter0_dd_2449_3797" x="0" y="0" width="52" height="52" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2449_3797"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_2449_3797" result="effect2_dropShadow_2449_3797"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2449_3797" result="shape"/>
</filter>
</defs>
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { default as PortraitView } from "./PortraitView/PortraitView";

View File

@ -1,12 +1,14 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useLocale } from "next-intl";
import clsx from "clsx";
import { Badge, Button, Icon, IconName, Typography } from "@/components/ui";
import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { stripLocale } from "@/shared/utils/path";
import Logo from "../Logo/Logo";
@ -29,6 +31,14 @@ export default function Header({
}: HeaderProps) {
const { totalUnreadCount } = useChats();
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
// Hide header on portraits page
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
if (isPortraitsPage) return null;
const handleBack = () => {
router.back();

View File

@ -24,10 +24,11 @@ export default function NavigationBar() {
const pathnameWithoutLocale = stripLocale(pathname, locale);
const { totalUnreadCount } = useChats();
// Hide navigation bar on retaining funnel pages
// Hide navigation bar on retaining funnel and portraits pages
const isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining");
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
if (isRetainingFunnel) return null;
if (isRetainingFunnel || isPortraitsPage) return null;
return (
<>

View File

@ -2,11 +2,13 @@ import { CSSProperties, ReactNode } from "react";
import clsx from "clsx";
import {
AlertCircleIcon,
ArticleIcon,
ChatIcon,
CheckIcon,
ChevronIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClipboardIcon,
ClockIcon,
CrossIcon,
@ -14,6 +16,7 @@ import {
HomeIcon,
ImageIcon,
LeafIcon,
LoaderIcon,
MenuIcon,
MicrophoneIcon,
NotificationIcon,
@ -28,6 +31,7 @@ import {
} from "./icons";
export enum IconName {
AlertCircle,
Notification,
Search,
Menu,
@ -46,17 +50,20 @@ export enum IconName {
ReadStatus,
Pin,
ChevronLeft,
ChevronRight,
Clock,
PaperAirplane,
Check,
Thunderbolt,
Shield,
Loader,
}
const icons: Record<
IconName,
React.ComponentType<React.SVGProps<SVGSVGElement>>
> = {
[IconName.AlertCircle]: AlertCircleIcon,
[IconName.Notification]: NotificationIcon,
[IconName.Search]: SearchIcon,
[IconName.Menu]: MenuIcon,
@ -75,11 +82,13 @@ const icons: Record<
[IconName.ReadStatus]: ReadStatusIcon,
[IconName.Pin]: PinIcon,
[IconName.ChevronLeft]: ChevronLeftIcon,
[IconName.ChevronRight]: ChevronRightIcon,
[IconName.Clock]: ClockIcon,
[IconName.PaperAirplane]: PaperAirplaneIcon,
[IconName.Check]: CheckIcon,
[IconName.Thunderbolt]: ThunderboltIcon,
[IconName.Shield]: ShieldIcon,
[IconName.Loader]: LoaderIcon,
};
export type IconProps = {

View File

@ -0,0 +1,35 @@
import { SVGProps } from "react";
export default function AlertCircleIcon(props: SVGProps<SVGSVGElement>) {
const color = props?.color || "#EF4444";
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle
cx="12"
cy="12"
r="9"
stroke={color}
strokeWidth="2"
/>
<path
d="M12 8V12"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
<circle
cx="12"
cy="16"
r="1"
fill={color}
/>
</svg>
);
}

View File

@ -0,0 +1,23 @@
import { SVGProps } from "react";
export default function ChevronRightIcon(props: SVGProps<SVGSVGElement>) {
const color = props?.color || "#FFFFFF";
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.5 15L12.5 10L7.5 5"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,32 @@
import { SVGProps } from "react";
export default function LoaderIcon(props: SVGProps<SVGSVGElement>) {
const color = props?.color || "#3B82F6";
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle
cx="12"
cy="12"
r="9"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="4 4"
opacity="0.25"
/>
<path
d="M12 3C16.9706 3 21 7.02944 21 12"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}

View File

@ -1,8 +1,10 @@
export { default as AlertCircleIcon } from "./AlertCircle";
export { default as ArticleIcon } from "./Article";
export { default as ChatIcon } from "./Chat";
export { default as CheckIcon } from "./Check";
export { default as ChevronIcon } from "./Chevron";
export { default as ChevronLeftIcon } from "./ChevronLeft";
export { default as ChevronRightIcon } from "./ChevronRight";
export { default as ClipboardIcon } from "./Clipboard";
export { default as ClockIcon } from "./Clock";
export { default as CrossIcon } from "./Cross";
@ -10,6 +12,7 @@ export { default as HeartIcon } from "./Heart";
export { default as HomeIcon } from "./Home";
export { default as ImageIcon } from "./Image";
export { default as LeafIcon } from "./Leaf";
export { default as LoaderIcon } from "./Loader";
export { default as MenuIcon } from "./Menu";
export { default as MicrophoneIcon } from "./Microphone";
export { default as NotificationIcon } from "./Notification";

View File

@ -14,3 +14,6 @@ export const loadMeditations = cache(() =>
loadDashboard().then(d => d.meditations)
);
export const loadPalms = cache(() => loadDashboard().then(d => d.palmActions));
export const loadPortraits = cache(() =>
loadDashboard().then(d => d.partnerPortraits || [])
);

View File

@ -49,11 +49,23 @@ export const ActionSchema = z.object({
});
export type Action = z.infer<typeof ActionSchema>;
/* ---------- Partner Portrait ---------- */
export const PartnerPortraitSchema = z.object({
_id: z.string(),
title: z.string(),
status: z.enum(["queued", "processing", "done", "error"]),
imageUrl: z.string().url().nullable(),
createdAt: z.string(),
finishedAt: z.string().nullable(),
});
export type PartnerPortrait = z.infer<typeof PartnerPortraitSchema>;
/* ---------- Итоговый ответ /dashboard ---------- */
export const DashboardSchema = z.object({
assistants: z.array(AssistantSchema),
compatibilityActions: z.array(ActionSchema),
palmActions: z.array(ActionSchema),
meditations: z.array(ActionSchema),
partnerPortraits: z.array(PartnerPortraitSchema).optional(),
});
export type DashboardData = z.infer<typeof DashboardSchema>;

View File

@ -16,7 +16,7 @@ const ProfileSchema = z.object({
address: z.string(),
})
.optional(),
name: z.string(),
name: z.string().optional(),
birthdate: z.string(),
gender: z.string(),
age: z.number(),
@ -39,7 +39,7 @@ const PartnerSchema = z
export const UserSchema = z.object({
ipLookup: IpLookupSchema,
profile: ProfileSchema,
profile: ProfileSchema.optional(),
partner: PartnerSchema,
_id: z.string(),
initialIp: z.string().optional(),

View File

@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
interface GenerationStatus {
id: string;
status: "queued" | "processing" | "done" | "error";
imageUrl?: string | null;
}
export function useGenerationStatus(jobId: string, initialStatus: string) {
const [status, setStatus] = useState(initialStatus);
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
// Don't poll if already done or error
if (status === "done" || status === "error") {
return;
}
const pollStatus = async () => {
try {
// Use server-side API route that can access HttpOnly cookies
const response = await fetch(`/api/generations/${jobId}/status`, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error("Failed to fetch status");
}
const data: GenerationStatus = await response.json();
setStatus(data.status);
if (data.status === "done" && data.imageUrl) {
setImageUrl(data.imageUrl);
}
} catch {
// Silently fail - don't break UI if polling fails
setStatus("error");
}
};
// Poll immediately
pollStatus();
// Then poll every 5 seconds
const interval = setInterval(pollStatus, 5000);
return () => clearInterval(interval);
}, [jobId, status]);
return { status, imageUrl };
}

View File

@ -160,7 +160,11 @@ class HttpClient {
console.error(`📦 Raw Data:`, data);
console.error(`📋 Schema Expected:`, schema._def);
console.error(`⚠️ Validation Error:`, error);
console.error(`🔍 Data Type:`, typeof data, data === null ? "(null)" : "");
console.error(
`🔍 Data Type:`,
typeof data,
data === null ? "(null)" : ""
);
throw error;
}
} else {