commit
550b3fc98c
@ -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: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
7
src/app/[locale]/(core)/portraits/[id]/layout.tsx
Normal file
7
src/app/[locale]/(core)/portraits/[id]/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function PortraitLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
28
src/app/[locale]/(core)/portraits/[id]/page.tsx
Normal file
28
src/app/[locale]/(core)/portraits/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/app/api/generations/[id]/status/route.ts
Normal file
48
src/app/api/generations/[id]/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
1
src/components/domains/portraits/index.ts
Normal file
1
src/components/domains/portraits/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as PortraitView } from "./PortraitView/PortraitView";
|
||||
@ -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();
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
35
src/components/ui/Icon/icons/AlertCircle.tsx
Normal file
35
src/components/ui/Icon/icons/AlertCircle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ui/Icon/icons/ChevronRight.tsx
Normal file
23
src/components/ui/Icon/icons/ChevronRight.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/ui/Icon/icons/Loader.tsx
Normal file
32
src/components/ui/Icon/icons/Loader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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 || [])
|
||||
);
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
56
src/hooks/generations/useGenerationStatus.ts
Normal file
56
src/hooks/generations/useGenerationStatus.ts
Normal 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 };
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user