diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index e546aa4a..ef2b7e75 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -151,7 +151,7 @@ describe("CreatePlan Component", () => { renderWithProviders(); expect( - screen.getByText("studio.plan.form_field.details"), + screen.getByRole("heading", { name: "Plan Details" }), ).toBeInTheDocument(); }); @@ -555,8 +555,7 @@ describe("CreatePlan Component", () => { await waitFor(() => { expect(axiosInstance.put).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", - expect.any(Object), - expect.any(Object), + expect.objectContaining({ title: "Updated Plan" }), ); }); }); @@ -588,7 +587,6 @@ describe("CreatePlan Component", () => { expect(putSpy).toHaveBeenCalledWith( "/api/v1/cms/plans/plan-123", expect.objectContaining({ start_date: null }), - expect.any(Object), ); }); }); diff --git a/src/components/routes/create-plan/CreatePlan.tsx b/src/components/routes/create-plan/CreatePlan.tsx index 14db40b9..3435e400 100644 --- a/src/components/routes/create-plan/CreatePlan.tsx +++ b/src/components/routes/create-plan/CreatePlan.tsx @@ -5,7 +5,6 @@ import { IoCalendarClearOutline, IoInformationCircleOutline, } from "react-icons/io5"; - import { useState, useRef, useEffect } from "react"; import { format } from "date-fns"; import { Textarea } from "@/components/ui/atoms/textarea"; @@ -35,12 +34,7 @@ import { } from "@/components/ui/atoms/tooltip"; export const getPlan = async (plan_id: string) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.get(`/api/v1/cms/plans/${plan_id}`); return data; }; @@ -51,26 +45,15 @@ export const updatePlan = async ({ plan_id: string; formdata: z.infer; }) => { - const accessToken = sessionStorage.getItem("accessToken"); const { data } = await axiosInstance.put( `/api/v1/cms/plans/${plan_id}`, formdata, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, ); return data; }; export const postPlan = async (formdata: z.infer) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata); return data; }; @@ -155,7 +138,7 @@ const Createplan = () => { }); const updatePlanMutation = useMutation({ mutationFn: updatePlan, - onSuccess: (_) => { + onSuccess: () => { toast.success("Plan updated successfully!", { description: "Your plan has been updated and is now available.", }); @@ -210,13 +193,15 @@ const Createplan = () => { }); setIsImageDialogOpen(false); toast.success("Image uploaded successfully!"); - } catch (error: any) { - if (error?.response?.status === 413) { + } catch (error: unknown) { + if ( + (error as { response?: { status?: number } }).response?.status === 413 + ) { toast.error("Failed to update Image", { description: "file exceeds the maximum size of 1MB", }); } else { - console.error("Image upload failed:", error); + console.error("Image upload failed:", error as Error); toast.error("Failed to upload image"); } } finally { @@ -239,7 +224,7 @@ const Createplan = () => {

- {t("studio.plan.form_field.details")} + Plan {isCreateMode ? "Details" : "Edit"}

diff --git a/src/components/routes/create-series/CreateSeries.tsx b/src/components/routes/create-series/CreateSeries.tsx new file mode 100644 index 00000000..8cdfecf7 --- /dev/null +++ b/src/components/routes/create-series/CreateSeries.tsx @@ -0,0 +1,551 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useBlocker, useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslate } from "@tolgee/react"; +import { IoMdAdd, IoMdClose } from "react-icons/io"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { toast } from "sonner"; +import { Pecha } from "@/components/ui/shadimport"; +import { Textarea } from "@/components/ui/atoms/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/atoms/tooltip"; +import ImageContentData from "@/components/ui/molecules/modals/image-upload/ImageContentData"; +import { uploadImageToS3 } from "@/components/routes/task/api/taskApi"; +import { PLAN_LANGUAGE } from "@/lib/constant"; +import type { LanguageCode } from "@/schema/SeriesSchema"; +import { useSeriesForm } from "@/components/routes/create-series/hooks/useSeriesForm"; +import PlanSearchSelector from "@/components/routes/create-series/components/PlanSearchSelector"; +import { + buildSeriesUpdateBody, + getSeries, + mapSeriesDetailToFormData, + postSeries, + putUpdateSeries, +} from "@/components/routes/create-series/api/seriesApi"; +import type { SeriesFormData } from "@/schema/SeriesSchema"; + +const CreateSeries = () => { + const { seriesId } = useParams<{ seriesId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { t } = useTranslate(); + const isNew = !seriesId; + const { + form, + languages, + plans, + imageUrl, + addedLanguages, + availableLanguages, + canSubmit, + addLanguage, + removeLanguage, + setImageUrl, + } = useSeriesForm(); + + const { + data: seriesData, + isLoading: isSeriesLoading, + isError: isSeriesError, + error: seriesError, + } = useQuery({ + queryKey: ["series", seriesId], + queryFn: () => getSeries(seriesId!), + enabled: Boolean(seriesId) && !isNew, + refetchOnWindowFocus: false, + }); + + const seriesHydratedIdRef = useRef(null); + + useEffect(() => { + seriesHydratedIdRef.current = null; + }, [seriesId]); + + useEffect(() => { + if (isNew || !seriesData) return; + if (seriesHydratedIdRef.current === seriesData.id) return; + seriesHydratedIdRef.current = seriesData.id; + form.reset(mapSeriesDetailToFormData(seriesData)); + setImagePreview(seriesData.image || null); + setSelectedImage(null); + }, [isNew, seriesData, form]); + + const [activePlansLanguage, setActivePlansLanguage] = + useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [isImageDialogOpen, setIsImageDialogOpen] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); + const [showNavigationDialog, setShowNavigationDialog] = useState(false); + const fileInputRef = useRef(null); + + const orderedAddedLanguages = useMemo( + () => + PLAN_LANGUAGE.map((l) => l.value as LanguageCode).filter( + (code) => languages[code] != null, + ), + [languages], + ); + + useEffect(() => { + if (orderedAddedLanguages.length === 0) { + setActivePlansLanguage(null); + return; + } + if ( + activePlansLanguage == null || + !orderedAddedLanguages.includes(activePlansLanguage) + ) { + setActivePlansLanguage(orderedAddedLanguages[0]); + } + }, [orderedAddedLanguages, activePlansLanguage]); + + const canUpdate = form.formState.isDirty; + const hasUnsavedChanges = canUpdate && !form.formState.isSubmitSuccessful; + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname, + ); + + useEffect(() => { + if (blocker.state === "blocked") { + setShowNavigationDialog(true); + } + }, [blocker.state]); + + const saveSeriesMutation = useMutation({ + mutationFn: async (input: { data: SeriesFormData; featured: boolean }) => { + const body = buildSeriesUpdateBody(input.data, input.featured); + if (isNew) { + const created = await postSeries(body); + return { id: String(created.id) }; + } + await putUpdateSeries({ + seriesId: seriesId!, + body, + }); + return { id: seriesId! }; + }, + onSuccess: () => { + toast.success( + isNew ? "Series created successfully!" : "Series updated successfully!", + ); + void queryClient.invalidateQueries({ queryKey: ["dashboard-items"] }); + if (seriesId && !isNew) { + void queryClient.invalidateQueries({ queryKey: ["series", seriesId] }); + } + if (isNew) { + form.reset({ languages: {}, plans: {}, image_url: "" }); + setSelectedImage(null); + setImagePreview(null); + setActivePlansLanguage(null); + } + navigate("/dashboard"); + }, + onError: (error: Error) => { + toast.error( + isNew ? "Failed to create series" : "Failed to update series", + { + description: error.message, + }, + ); + }, + }); + + const handleConfirmNavigation = () => { + setShowNavigationDialog(false); + blocker.proceed?.(); + }; + + const handleCancelNavigation = () => { + setShowNavigationDialog(false); + blocker.reset?.(); + }; + + const handleRemoveImage = () => { + setSelectedImage(null); + setImagePreview(null); + setImageUrl(""); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleImageUpload = async (file: File) => { + setIsImageUploading(true); + try { + const { image, key } = await uploadImageToS3( + file, + isNew ? "" : seriesId || "", + ); + const imageUrlUploaded = image.original; + setImagePreview(imageUrlUploaded); + setSelectedImage(file); + setImageUrl(key); + setIsImageDialogOpen(false); + toast.success("Image uploaded successfully!"); + } catch (error: unknown) { + const err = error as { response?: { status?: number } }; + if (err?.response?.status === 413) { + toast.error("Failed to upload image", { + description: "File exceeds the maximum size of 1MB", + }); + } else { + console.error("Image upload failed:", error); + toast.error("Failed to upload image"); + } + } finally { + setIsImageUploading(false); + } + }; + + const onSubmit = form.handleSubmit((data) => { + const featured = isNew ? false : (seriesData?.featured ?? false); + saveSeriesMutation.mutate({ data, featured }); + }); + + if (!isNew && isSeriesLoading) { + return ( +
+ Loading series… +
+ ); + } + + if (!isNew && isSeriesError) { + return ( +
+

+ {(seriesError as Error)?.message || "Could not load this series."} +

+ navigate("/dashboard")}> + {t("common.button.cancel")} + +
+ ); + } + + const submitEnabled = + canSubmit && + !saveSeriesMutation.isPending && + imageUrl.trim().length > 0 && + (isNew || !!seriesData); + + const saveDisabled = !submitEnabled || (!isNew && !form.formState.isDirty); + + const saveLabel = saveSeriesMutation.isPending + ? isNew + ? "Creating…" + : "Saving…" + : isNew + ? "Create series" + : "Save changes"; + + return ( +
+
+

+ {isNew ? "Series details" : "Series Edit"} +

+ + +
+
+ {orderedAddedLanguages.map((code) => { + const label = + code === "EN" + ? "English" + : code === "BO" + ? "Tibetan" + : code === "ZH" + ? "Chinese" + : code; + + return ( +
+ + ( + + + {label} title + + + + + + + )} + /> + + ( + + + {label} description + + +