diff --git a/next.config.ts b/next.config.ts index dd5ad28..19e9765 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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: "/**", + }, ], }, }; diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx index 797edfa..4f4e81f 100644 --- a/src/app/[locale]/(core)/page.tsx +++ b/src/app/[locale]/(core)/page.tsx @@ -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 (
- + + + }> {children}; +} diff --git a/src/app/[locale]/(core)/portraits/[id]/page.tsx b/src/app/[locale]/(core)/portraits/[id]/page.tsx new file mode 100644 index 0000000..431d38a --- /dev/null +++ b/src/app/[locale]/(core)/portraits/[id]/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/api/generations/[id]/status/route.ts b/src/app/api/generations/[id]/status/route.ts new file mode 100644 index 0000000..e8e4763 --- /dev/null +++ b/src/app/api/generations/[id]/status/route.ts @@ -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( + 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 } + ); + } +} diff --git a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx index 15b7e56..6d99bf9 100644 --- a/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx +++ b/src/components/domains/chat/GlobalNewMessagesBanner/GlobalNewMessagesBanner.tsx @@ -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); diff --git a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss new file mode 100644 index 0000000..79be070 --- /dev/null +++ b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss @@ -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); + } +} diff --git a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx new file mode 100644 index 0000000..ba1b7ba --- /dev/null +++ b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx @@ -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 = () => ( + + + + + + + + + + +); + +const getStatusConfig = (status: PartnerPortrait["status"]) => { + switch (status) { + case "done": + return { + icon: , + text: "Ready", + showCheckmark: true, + className: styles.statusDone, + }; + case "processing": + return { + icon: , + text: "Generating...", + showCheckmark: false, + className: styles.statusProcessing, + }; + case "queued": + return { + icon: , + text: "In Queue", + showCheckmark: false, + className: styles.statusQueued, + }; + case "error": + return { + icon: , + 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 ( + +
+ {imageUrl ? ( + {title} + ) : ( +
+ +
+ )} +
+ +
+
+ + {title} + + + Finding the One Guide + +
+ +
+
+ {statusConfig.icon} + + {statusConfig.text} + + {statusConfig.showCheckmark && ( + + )} +
+ + {status === "done" && ( + + )} +
+
+
+ ); +} diff --git a/src/components/domains/dashboard/cards/index.ts b/src/components/domains/dashboard/cards/index.ts index 8f676e1..65540f0 100644 --- a/src/components/domains/dashboard/cards/index.ts +++ b/src/components/domains/dashboard/cards/index.ts @@ -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"; diff --git a/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.module.scss b/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.module.scss new file mode 100644 index 0000000..9798006 --- /dev/null +++ b/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.module.scss @@ -0,0 +1,5 @@ +.sectionContent { + display: flex; + flex-direction: column; + gap: 16px; +} diff --git a/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.tsx b/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.tsx new file mode 100644 index 0000000..805ea00 --- /dev/null +++ b/src/components/domains/dashboard/sections/HoroscopeSection/HoroscopeSection.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.module.scss b/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.module.scss new file mode 100644 index 0000000..9a5ac3e --- /dev/null +++ b/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.module.scss @@ -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; +} diff --git a/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.tsx b/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.tsx new file mode 100644 index 0000000..d3c0e71 --- /dev/null +++ b/src/components/domains/dashboard/sections/PortraitsSection/PortraitsSection.tsx @@ -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 ( +
+ + {portraits.map(portrait => ( + + + + ))} + +
+ ); +} diff --git a/src/components/domains/dashboard/sections/index.ts b/src/components/domains/dashboard/sections/index.ts index f373901..989e957 100644 --- a/src/components/domains/dashboard/sections/index.ts +++ b/src/components/domains/dashboard/sections/index.ts @@ -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"; diff --git a/src/components/domains/portraits/PortraitView/PortraitView.module.scss b/src/components/domains/portraits/PortraitView/PortraitView.module.scss new file mode 100644 index 0000000..f78901e --- /dev/null +++ b/src/components/domains/portraits/PortraitView/PortraitView.module.scss @@ -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; + } +} diff --git a/src/components/domains/portraits/PortraitView/PortraitView.tsx b/src/components/domains/portraits/PortraitView/PortraitView.tsx new file mode 100644 index 0000000..9731ba3 --- /dev/null +++ b/src/components/domains/portraits/PortraitView/PortraitView.tsx @@ -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 ( +
+ {/* Header with back button and title */} +
+ + + {title} + +
+ + {/* Portrait Image */} +
+
+
+ Partner Portrait +
+ + {/* Download Button */} + +
+
+
+ ); +} diff --git a/src/components/domains/portraits/index.ts b/src/components/domains/portraits/index.ts new file mode 100644 index 0000000..c11006c --- /dev/null +++ b/src/components/domains/portraits/index.ts @@ -0,0 +1 @@ +export { default as PortraitView } from "./PortraitView/PortraitView"; diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 9477522..4cf60a2 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -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(); diff --git a/src/components/layout/NavigationBar/NavigationBar.tsx b/src/components/layout/NavigationBar/NavigationBar.tsx index 4ca4a7f..77711bf 100644 --- a/src/components/layout/NavigationBar/NavigationBar.tsx +++ b/src/components/layout/NavigationBar/NavigationBar.tsx @@ -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 ( <> diff --git a/src/components/ui/Icon/Icon.tsx b/src/components/ui/Icon/Icon.tsx index 168fdba..3be66a3 100644 --- a/src/components/ui/Icon/Icon.tsx +++ b/src/components/ui/Icon/Icon.tsx @@ -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> > = { + [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 = { diff --git a/src/components/ui/Icon/icons/AlertCircle.tsx b/src/components/ui/Icon/icons/AlertCircle.tsx new file mode 100644 index 0000000..e96fa1b --- /dev/null +++ b/src/components/ui/Icon/icons/AlertCircle.tsx @@ -0,0 +1,35 @@ +import { SVGProps } from "react"; + +export default function AlertCircleIcon(props: SVGProps) { + const color = props?.color || "#EF4444"; + return ( + + + + + + ); +} diff --git a/src/components/ui/Icon/icons/ChevronRight.tsx b/src/components/ui/Icon/icons/ChevronRight.tsx new file mode 100644 index 0000000..bcf0502 --- /dev/null +++ b/src/components/ui/Icon/icons/ChevronRight.tsx @@ -0,0 +1,23 @@ +import { SVGProps } from "react"; + +export default function ChevronRightIcon(props: SVGProps) { + const color = props?.color || "#FFFFFF"; + return ( + + + + ); +} diff --git a/src/components/ui/Icon/icons/Loader.tsx b/src/components/ui/Icon/icons/Loader.tsx new file mode 100644 index 0000000..3931507 --- /dev/null +++ b/src/components/ui/Icon/icons/Loader.tsx @@ -0,0 +1,32 @@ +import { SVGProps } from "react"; + +export default function LoaderIcon(props: SVGProps) { + const color = props?.color || "#3B82F6"; + return ( + + + + + ); +} diff --git a/src/components/ui/Icon/icons/index.ts b/src/components/ui/Icon/icons/index.ts index b7fac59..16e4612 100644 --- a/src/components/ui/Icon/icons/index.ts +++ b/src/components/ui/Icon/icons/index.ts @@ -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"; diff --git a/src/entities/dashboard/loaders.ts b/src/entities/dashboard/loaders.ts index aa840d6..c1d5fa0 100644 --- a/src/entities/dashboard/loaders.ts +++ b/src/entities/dashboard/loaders.ts @@ -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 || []) +); diff --git a/src/entities/dashboard/types.ts b/src/entities/dashboard/types.ts index d1a6806..fc2f42c 100644 --- a/src/entities/dashboard/types.ts +++ b/src/entities/dashboard/types.ts @@ -49,11 +49,23 @@ export const ActionSchema = z.object({ }); export type Action = z.infer; +/* ---------- 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; + /* ---------- Итоговый ответ /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; diff --git a/src/hooks/generations/useGenerationStatus.ts b/src/hooks/generations/useGenerationStatus.ts new file mode 100644 index 0000000..5b2c766 --- /dev/null +++ b/src/hooks/generations/useGenerationStatus.ts @@ -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(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 }; +}