Skip to content
Open
109 changes: 109 additions & 0 deletions frontend/src/app/[lang]/[aidolId]/group/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

import { useToast } from "@/app/providers/Toast";
import { GroupPlanningForm } from "@/components/group-creation";
import { Header } from "@/components/Header";
import { Loading } from "@/components/Loading";
import { AIdolRepository } from "@/repositories/AIdolRepository";
import type { AIdol } from "@/schemas";
import { getApiService } from "@/services/ApiService";

interface GroupPageProps {
params: { lang: string; aidolId: string };
}

export default function GroupPage({ params }: GroupPageProps) {
const { t } = useTranslation();
const router = useRouter();
const { showToast } = useToast();
const { lang, aidolId } = params;

const [aidol, setAidol] = useState<AIdol | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);

const aidolRepository = useMemo(
() => new AIdolRepository(getApiService()),
[],
);

useEffect(() => {
const fetchAidol = async () => {
setIsLoading(true);
try {
const response = await aidolRepository.getOne({ id: aidolId });
setAidol(response.data);
} catch (error) {
console.error("Failed to fetch aidol:", error);
showToast(t("aidol:groupPlanning.error.load"), "error");
} finally {
setIsLoading(false);
}
};

void fetchAidol();
}, [aidolId, aidolRepository, showToast, t]);

const handleGenerateImage = async (
prompt: string,
): Promise<string | null> => {
setIsGeneratingImage(true);
try {
const response = await aidolRepository.generateImage({ prompt });
return response.data.imageUrl;
} catch (error) {
console.error("Failed to generate image:", error);
showToast(t("aidol:groupPlanning.error.generate"), "error");
return null;
} finally {
setIsGeneratingImage(false);
}
};

const handleSubmit = async (data: { name: string; emblemUrl: string }) => {
setIsSubmitting(true);
try {
await aidolRepository.update({
id: aidolId,
variables: {
name: data.name,
profileImageUrl: data.emblemUrl,
},
});
router.push(`/${lang}/${aidolId}/complete`);
} catch (error) {
console.error("Failed to update aidol:", error);
showToast(t("aidol:groupPlanning.error.update"), "error");
} finally {
setIsSubmitting(false);
}
};

if (isLoading) {
return (
<div className="bg-base-100 flex h-screen flex-col">
<Header title={t("aidol:groupPlanning.header")} />
<Loading />
</div>
);
}

return (
<div className="bg-base-100 flex h-screen flex-col">
<Header title={t("aidol:groupPlanning.header")} />
<GroupPlanningForm
initialName={aidol?.name}
initialEmblemUrl={aidol?.profileImageUrl}
onSubmit={handleSubmit}
onGenerateImage={handleGenerateImage}
isLoading={isSubmitting}
isGeneratingImage={isGeneratingImage}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface HeaderProps {

export function Header({ title, rightContent }: HeaderProps) {
return (
<header className="h-header bg-base-100 flex items-center justify-between px-6 py-4">
<header className="h-header bg-base-100 flex shrink-0 items-center justify-between px-6 py-4">
<h1 className="text-headline-s text-base-content">{title}</h1>
{rightContent && <div className="flex items-center">{rightContent}</div>}
</header>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/creation/StepCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function StepCard({ step, title, children }: StepCardProps) {
const formattedStep = step.toString().padStart(2, "0");

return (
<div className="bg-base-200 flex flex-col gap-6 rounded-lg p-6">
<div className="bg-base-200 flex w-full flex-col gap-6 rounded-lg p-6">
<span className="text-title-s text-primary">{formattedStep}</span>
<h3 className="text-title-s text-base-content">{title}</h3>
<div className="flex flex-col gap-4">{children}</div>
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/components/group-creation/EmblemStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { SparklesIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";

import { ImagePreview } from "@/components/companion/ImagePreview";
import { StepCard } from "@/components/creation/StepCard";

interface EmblemStepProps {
emblemUrl: string;
isGeneratingImage: boolean;
onGenerate: (prompt: string) => void;
}

export function EmblemStep({
emblemUrl,
isGeneratingImage,
onGenerate,
}: EmblemStepProps) {
const { t } = useTranslation();
const [prompt, setPrompt] = useState("");

const handleGenerate = () => {
if (prompt.trim()) {
onGenerate(prompt.trim());
}
};

return (
<StepCard step={2} title={t("aidol:groupPlanning.step2Title")}>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t("aidol:groupPlanning.promptPlaceholder")}
className="border-base-300 text-body-m placeholder:text-base-300 w-full rounded-lg border bg-white px-4 py-3 text-black focus:outline-none"
maxLength={200}
disabled={isGeneratingImage}
/>
<button
type="button"
onClick={handleGenerate}
disabled={!prompt.trim() || isGeneratingImage}
className={clsx(
"btn btn-lg text-label-l w-full rounded-lg border-0 py-4 text-white shadow-none",
prompt.trim() && !isGeneratingImage ? "bg-primary" : "bg-primary/20",
)}
>
{emblemUrl
? t("aidol:groupPlanning.regenerate")
: t("aidol:groupPlanning.generate")}
</button>
<div className="border-base-300 bg-base-100 flex h-72 items-center justify-center overflow-hidden rounded-lg border">
{isGeneratingImage ? (
<span className="loading loading-spinner loading-lg text-primary" />
) : emblemUrl ? (
<ImagePreview url={emblemUrl} alt="emblem" />
) : (
<div className="text-neutral flex flex-col items-center gap-4">
<SparklesIcon className="size-6" />
<p className="text-body-m text-center whitespace-pre-line">
{t("aidol:groupPlanning.emptyImage")}
</p>
</div>
)}
</div>
</StepCard>
);
}
107 changes: 107 additions & 0 deletions frontend/src/components/group-creation/GroupPlanningForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import clsx from "clsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";

import { StepCard } from "@/components/creation/StepCard";

import { EmblemStep } from "./EmblemStep";

interface GroupPlanningFormProps {
initialStep?: 1 | 2;
initialName?: string;
initialEmblemUrl?: string;
onSubmit: (data: { name: string; emblemUrl: string }) => void;
onGenerateImage: (prompt: string) => Promise<string | null>;
isLoading: boolean;
isGeneratingImage: boolean;
}

export function GroupPlanningForm({
initialStep = 1,
initialName = "",
initialEmblemUrl = "",
onSubmit,
onGenerateImage,
isLoading,
isGeneratingImage,
}: GroupPlanningFormProps) {
const { t } = useTranslation();
const [step, setStep] = useState(initialStep);
const [name, setName] = useState(initialName);
const [emblemUrl, setEmblemUrl] = useState(initialEmblemUrl);

const canProceedStep1 = name.trim().length > 0;
const canProceedStep2 = emblemUrl.length > 0;

const handleNext = () => {
if (step === 1 && canProceedStep1) {
setStep(2);
} else if (step === 2 && canProceedStep2) {
onSubmit({ name: name.trim(), emblemUrl });
}
};

const handleGenerateImage = async (prompt: string) => {
const url = await onGenerateImage(prompt);
if (url) {
setEmblemUrl(url);
}
};

const canProceed = step === 1 ? canProceedStep1 : canProceedStep2;

return (
<div className="flex flex-1 flex-col gap-6">
<div className="px-6 py-4">
<progress
className="progress progress-primary h-3 w-full"
value={step}
max={2}
/>
</div>

<div className="flex w-full flex-1 flex-col items-center gap-6 overflow-y-auto px-6 pb-24">
{step === 1 && (
<StepCard step={1} title={t("aidol:groupPlanning.step1Title")}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("aidol:groupPlanning.namePlaceholder")}
className="border-base-300 text-body-m placeholder:text-base-300 w-full rounded-lg border bg-white px-4 py-3 text-black focus:outline-none"
maxLength={20}
/>
</StepCard>
)}

{step === 2 && (
<EmblemStep
emblemUrl={emblemUrl}
isGeneratingImage={isGeneratingImage}
onGenerate={(prompt) => void handleGenerateImage(prompt)}
/>
)}
</div>

<div className="max-w-mobile fixed inset-x-0 bottom-0 z-10 mx-auto px-6 pb-6">
<div className="bg-base-100">
<button
type="button"
onClick={handleNext}
disabled={!canProceed || isLoading}
className={clsx(
"btn btn-lg text-label-l text-neutral-content w-full rounded-lg border-0 shadow-none",
canProceed && !isLoading ? "bg-neutral" : "bg-neutral/20",
)}
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
t("aidol:groupPlanning.next")
)}
</button>
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/group-creation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { EmblemStep } from "./EmblemStep";
export { GroupPlanningForm } from "./GroupPlanningForm";
16 changes: 16 additions & 0 deletions frontend/src/i18n/locales/en/aidol.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@
"completeDescription": "Start chatting with your dream idol now",
"viewProfile": "View Profile"
},
"groupPlanning": {
"header": "Group Planning",
"step1Title": "Name your group",
"step2Title": "Create your group emblem",
"namePlaceholder": "e.g.) Dreamers, Starlight",
"promptPlaceholder": "e.g.)",
"generate": "Generate Image",
"regenerate": "Regenerate Image",
"emptyImage": "Generated profile\nimage will\nappear here",
"next": "Next",
"error": {
"load": "Failed to load group info",
"generate": "Failed to generate image",
"update": "Failed to save group planning"
}
},
"landing": {
"hero": {
"title": {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/i18n/locales/es/aidol.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@
"completeDescription": "Empieza a chatear con tu idol soñado ahora",
"viewProfile": "Ver Perfil"
},
"groupPlanning": {
"header": "Planificación del Grupo",
"step1Title": "Nombra tu grupo",
"step2Title": "Crea el emblema del grupo",
"namePlaceholder": "ej.) Dreamers, Starlight",
"promptPlaceholder": "ej.)",
"generate": "Generar Imagen",
"regenerate": "Regenerar Imagen",
"emptyImage": "La imagen del perfil\ngenerada aparecerá\naquí",
"next": "Siguiente",
"error": {
"load": "Error al cargar la información del grupo",
"generate": "Error al generar la imagen",
"update": "Error al guardar la planificación"
}
},
"landing": {
"hero": {
"title": {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/i18n/locales/id/aidol.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@
"completeDescription": "Langsung ngobrol sama idol impianmu sekarang",
"viewProfile": "Lihat Profil"
},
"groupPlanning": {
"header": "Perencanaan Grup",
"step1Title": "Beri nama grupmu",
"step2Title": "Buat emblem grup",
"namePlaceholder": "cth.) Dreamers, Starlight",
"promptPlaceholder": "cth.)",
"generate": "Buat Gambar",
"regenerate": "Buat Ulang Gambar",
"emptyImage": "Gambar profil\nyang dibuat akan\nmuncul di sini",
"next": "Selanjutnya",
"error": {
"load": "Gagal memuat info grup",
"generate": "Gagal membuat gambar",
"update": "Gagal menyimpan perencanaan grup"
}
},
"landing": {
"hero": {
"title": {
Expand Down
Loading