diff --git a/.gitignore b/.gitignore index 6950dfae..b992d83d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ dist-ssr *.sw? .env .env.production -coverage \ No newline at end of file +coverage + +.cursor \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 98e7a928..482168bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@tanstack/react-query-devtools": "^5.100.6", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -3467,9 +3468,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.85.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", - "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz", + "integrity": "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.6.tgz", + "integrity": "sha512-2SiNwlOiAdTbqktCSmwlXZH8x8mckSbES2O0bdr3qZNhdQl5DCtImZx0S3HGeNHWTIkzTaHx2Isg+bD4M3WRIg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -3477,18 +3489,36 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.85.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", - "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.6.tgz", + "integrity": "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.6.tgz", + "integrity": "sha512-sz3ksMKA2t1rx0+Odzb0x1A3pXH/SVf7fzlzd3sKXzwXz8980f5sbOwfQD6+UfTG8G4Y2KaIg9e3sBn+uC4VTg==", + "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.85.5" + "@tanstack/query-devtools": "5.100.6" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { + "@tanstack/react-query": "^5.100.6", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 8243d8fe..56f770ac 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@tanstack/react-query-devtools": "^5.100.6", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/src/App.tsx b/src/App.tsx index dd1751a0..bd45b626 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { ACCESS_TOKEN, LANGUAGE, REFRESH_TOKEN } from "./lib/constant"; import { useAuth } from "./config/auth-context"; import { useMutation } from "@tanstack/react-query"; import axiosInstance from "./config/axios-config"; +import { AUTH_ROUTE_PATHS, ROUTES } from "./routes/paths"; function App() { const location = useLocation(); @@ -13,14 +14,7 @@ function App() { const navigate = useNavigate(); const [intervalId, setIntervalId] = useState(null); - const authRoutes = [ - "/login", - "/signup", - "/forgot-password", - "/reset-password", - "/verify-email", - ]; - const hideNavbar = authRoutes.includes(location.pathname); + const hideNavbar = AUTH_ROUTE_PATHS.includes(location.pathname); const loginMutation = useMutation({ mutationFn: async (refreshToken: string) => { const { data } = await axiosInstance.post( @@ -41,7 +35,7 @@ function App() { onError: () => { sessionStorage.removeItem(ACCESS_TOKEN); localStorage.removeItem(REFRESH_TOKEN); - navigate("/login"); + navigate(ROUTES.login); }, }); diff --git a/src/components/routes/create-plan/CreatePlan.test.tsx b/src/components/routes/create-plan/CreatePlan.test.tsx index 872d29c3..e546aa4a 100644 --- a/src/components/routes/create-plan/CreatePlan.test.tsx +++ b/src/components/routes/create-plan/CreatePlan.test.tsx @@ -66,7 +66,7 @@ describe("CreatePlan Component", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(useParams).mockReturnValue({}); - vi.spyOn(axiosInstance, "post").mockImplementation((url) => { + vi.spyOn(axiosInstance, "post").mockImplementation((url: string) => { if (url.includes("/media/upload")) { return Promise.resolve({ data: { @@ -81,21 +81,70 @@ describe("CreatePlan Component", () => { }, }); } + if (url.includes("/cms/tags")) { + return Promise.resolve({ + data: { + id: "tag-new", + name: "Newtag", + image: null, + image_key: null, + description: null, + plan_ids: [], + }, + }); + } return Promise.resolve({ data: {} }); }); - vi.spyOn(axiosInstance, "get").mockResolvedValue({ - data: { - id: "plan-123", - title: "Existing Plan", - description: "Existing description", - total_days: 14, - difficulty_level: "Beginner", - plan_image_url: "https://example.com/image.jpg", - image_url: "https://example.com/image.jpg", - tags: ["meditation"], - language: "en", - start_date: "2026-04-30T00:00:00Z", - }, + vi.spyOn(axiosInstance, "get").mockImplementation((url: string) => { + if (url.includes("/cms/tags")) { + return Promise.resolve({ + data: { + tags: [ + { + id: "tag-1", + name: "Meditation", + image: null, + image_key: null, + description: null, + plan_ids: [], + }, + { + id: "tag-2", + name: "Daily tipitaka", + image: null, + image_key: null, + description: null, + plan_ids: [], + }, + ], + skip: 0, + limit: 500, + total: 2, + }, + }); + } + return Promise.resolve({ + data: { + id: "plan-123", + title: "Existing Plan", + description: "Existing description", + total_days: 14, + difficulty_level: "Beginner", + plan_image_url: "https://example.com/image.jpg", + image_url: "https://example.com/image.jpg", + tags: [ + { + id: "tag-1", + name: "Meditation", + image: null, + image_key: null, + description: null, + }, + ], + language: "en", + start_date: "2026-04-30T00:00:00Z", + }, + }); }); }); it("renders create plan form with main heading", () => { @@ -147,7 +196,7 @@ describe("CreatePlan Component", () => { screen.getByText("studio.dashboard.cover_image"), ).toBeInTheDocument(); expect( - screen.getByText("studio.plan.cover_image.description"), + screen.getByText("studio.plan.cover_image.constraints"), ).toBeInTheDocument(); }); @@ -162,7 +211,7 @@ describe("CreatePlan Component", () => { it("renders submit button", () => { renderWithProviders(); - const submitButton = screen.getByText("studio.plan.update_button"); + const submitButton = screen.getByText("studio.plan.next_button"); expect(submitButton).toBeInTheDocument(); expect(submitButton.tagName).toBe("BUTTON"); }); @@ -211,7 +260,7 @@ describe("CreatePlan Component", () => { screen.getByText("studio.dashboard.cover_image"), ).toBeInTheDocument(); expect( - screen.getByText("studio.plan.cover_image.description"), + screen.getByText("studio.plan.cover_image.constraints"), ).toBeInTheDocument(); const uploadButton = screen.getByLabelText("Upload cover image"); expect(uploadButton).toBeInTheDocument(); @@ -301,7 +350,7 @@ describe("CreatePlan Component", () => { }); it("shows validation errors for required fields", async () => { - vi.mocked(useParams).mockReturnValue({ plan_id: "new" }); + vi.mocked(useParams).mockReturnValue({}); renderWithProviders(); const submitButton = screen.getByText("studio.plan.next_button"); fireEvent.click(submitButton); @@ -316,12 +365,53 @@ describe("CreatePlan Component", () => { ).toBeInTheDocument(); }); - it("handles tag input add and remove", () => { + it("handles tag search selection and remove", async () => { renderWithProviders(); - const tagInput = screen.getByPlaceholderText("Add a tag"); - fireEvent.change(tagInput, { target: { value: "tag1" } }); - fireEvent.keyDown(tagInput, { key: "Enter", code: "Enter" }); - expect(screen.getByText("Tag1")).toBeInTheDocument(); + const tagInput = screen.getByPlaceholderText("Search or add tags..."); + fireEvent.change(tagInput, { target: { value: "Med" } }); + fireEvent.focus(tagInput); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Meditation" }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Meditation" })); + expect(screen.getAllByText("Meditation").length).toBeGreaterThan(0); + + const removeButton = screen.getByRole("button", { + name: /Remove Meditation/i, + }); + fireEvent.click(removeButton); + expect( + screen.queryByRole("button", { name: /Remove Meditation/i }), + ).toBeNull(); + }); + + it("creates a new tag when typing a name that does not exist", async () => { + renderWithProviders(); + const tagInput = screen.getByPlaceholderText("Search or add tags..."); + fireEvent.change(tagInput, { target: { value: "Brand New Tag" } }); + fireEvent.focus(tagInput); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: 'Create "Brand New Tag"' }), + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByRole("button", { name: 'Create "Brand New Tag"' }), + ); + + await waitFor(() => { + expect(axiosInstance.post).toHaveBeenCalledWith( + "/api/v1/cms/tags", + expect.objectContaining({ name: "Brand New Tag" }), + expect.any(Object), + ); + }); }); it("shows no image preview initially", () => { @@ -350,7 +440,7 @@ describe("CreatePlan Component", () => { fireEvent.click(difficultyButton); const difficultyOption = screen.getByText("Beginner"); fireEvent.click(difficultyOption); - const submitButton = screen.getByText("studio.plan.update_button"); + const submitButton = screen.getByText("studio.plan.next_button"); fireEvent.click(submitButton); await waitFor(() => { expect(titleInput).toHaveValue("Test Plan"); @@ -396,7 +486,7 @@ describe("CreatePlan Component", () => { }); it("fetches and populates form in edit mode", async () => { - vi.mocked(useParams).mockReturnValue({ plan_id: "plan-123" }); + vi.mocked(useParams).mockReturnValue({ planId: "plan-123" }); renderWithProviders(); @@ -442,7 +532,7 @@ describe("CreatePlan Component", () => { }); it("submits update in edit mode", async () => { - vi.mocked(useParams).mockReturnValue({ plan_id: "plan-123" }); + vi.mocked(useParams).mockReturnValue({ planId: "plan-123" }); vi.spyOn(axiosInstance, "put").mockResolvedValue({ data: {} }); renderWithProviders(); @@ -472,7 +562,7 @@ describe("CreatePlan Component", () => { }); it("clears start_date when switching from specific to enroll mode on submit", async () => { - vi.mocked(useParams).mockReturnValue({ plan_id: "plan-123" }); + vi.mocked(useParams).mockReturnValue({ planId: "plan-123" }); const putSpy = vi .spyOn(axiosInstance, "put") .mockResolvedValue({ data: {} }); diff --git a/src/components/routes/create-plan/CreatePlan.tsx b/src/components/routes/create-plan/CreatePlan.tsx index 985b9603..14db40b9 100644 --- a/src/components/routes/create-plan/CreatePlan.tsx +++ b/src/components/routes/create-plan/CreatePlan.tsx @@ -1,15 +1,24 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { IoMdAdd, IoMdClose } from "react-icons/io"; -import { IoCalendarClearOutline } from "react-icons/io5"; +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"; import { useBlocker, useNavigate, useParams } from "react-router-dom"; +import { ROUTES } from "@/routes/paths"; import { planSchema } from "@/schema/PlanSchema"; import { z } from "zod"; import { useTranslate } from "@tolgee/react"; -import TagInput from "@/components/ui/molecules/tag-input/TagInput"; +import PlanTagSearchInput from "./PlanTagSearchInput"; +import { + planTagsToIds, + type PlanTagSummary, +} from "@/components/routes/tags/api/tagsApi"; import { DIFFICULTY, PLAN_LANGUAGE } from "@/lib/constant"; import { toBackendISO, fromBackendISO, isPastDate } from "@/lib/utils"; import axiosInstance from "@/config/axios-config"; @@ -18,6 +27,12 @@ import { toast } from "sonner"; import { Pecha } from "@/components/ui/shadimport"; import ImageContentData from "@/components/ui/molecules/modals/image-upload/ImageContentData"; import { uploadImageToS3 } from "../task/api/taskApi"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/atoms/tooltip"; export const getPlan = async (plan_id: string) => { const accessToken = sessionStorage.getItem("accessToken"); @@ -28,6 +43,7 @@ export const getPlan = async (plan_id: string) => { }); return data; }; + export const updatePlan = async ({ plan_id, formdata, @@ -47,6 +63,7 @@ export const updatePlan = async ({ ); return data; }; + export const postPlan = async (formdata: z.infer) => { const accessToken = sessionStorage.getItem("accessToken"); const { data } = await axiosInstance.post(`/api/v1/cms/plans`, formdata, { @@ -56,17 +73,21 @@ export const postPlan = async (formdata: z.infer) => { }); return data; }; + const Createplan = () => { const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); const fileInputRef = useRef(null); const [isImageDialogOpen, setIsImageDialogOpen] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); const [showNavigationDialog, setShowNavigationDialog] = useState(false); const [startDateMode, setStartDateMode] = useState<"enroll" | "specific">( "enroll", ); + const [isDateOpen, setIsDateOpen] = useState(false); - const { plan_id } = useParams(); + const { planId } = useParams<{ planId?: string }>(); + const isCreateMode = !planId; const { t } = useTranslate(); type PlanFormData = z.infer; const navigate = useNavigate(); @@ -85,27 +106,27 @@ const Createplan = () => { }); const { data: planData } = useQuery({ - queryKey: ["plan", plan_id], - queryFn: () => getPlan(plan_id!), - enabled: !!plan_id && plan_id !== "new", + queryKey: ["plan", planId], + queryFn: () => getPlan(planId!), + enabled: !!planId, refetchOnWindowFocus: false, }); useEffect(() => { - if (plan_id !== "new" && planData) { + if (planId && planData) { form.reset({ title: planData.title || "", description: planData.description || "", total_days: planData.total_days?.toString() || "", difficulty_level: planData.difficulty_level || "", image_url: planData.plan_image_url || "", - tags: planData.tags || [], + tags: planTagsToIds(planData.tags), language: planData.language || "", start_date: planData.start_date || null, }); setStartDateMode(planData.start_date ? "specific" : "enroll"); setImagePreview(planData.image_url ? `${planData.image_url}` : null); } - }, [plan_id, planData]); + }, [planId, planData]); const canUpdate = form.formState.isDirty; @@ -124,7 +145,7 @@ const Createplan = () => { form.reset(); setSelectedImage(null); setImagePreview(null); - navigate(`/plan/${data.id}/plan-details`); + navigate(ROUTES.plan(data.id)); }, onError: (error) => { toast.error("Failed to create plan", { @@ -138,7 +159,7 @@ const Createplan = () => { toast.success("Plan updated successfully!", { description: "Your plan has been updated and is now available.", }); - navigate("/dashboard"); + navigate(ROUTES.dashboard); }, onError: (error) => { toast.error("Failed to update plan", { @@ -171,11 +192,13 @@ const Createplan = () => { setShowNavigationDialog(false); blocker.reset?.(); }; + const handleImageUpload = async (file: File) => { + setIsImageUploading(true); try { const { image, key } = await uploadImageToS3( file, - plan_id === "new" ? "" : plan_id || "", + isCreateMode ? "" : planId || "", ); const imageUrl = image.original; const imageKey = key; @@ -196,6 +219,8 @@ const Createplan = () => { console.error("Image upload failed:", error); toast.error("Failed to upload image"); } + } finally { + setIsImageUploading(false); } }; @@ -204,8 +229,8 @@ const Createplan = () => { ...data, start_date: startDateMode === "specific" ? data.start_date : null, }; - if (plan_id !== "new") { - updatePlanMutation.mutate({ plan_id: plan_id!, formdata: payload }); + if (planId) { + updatePlanMutation.mutate({ plan_id: planId, formdata: payload }); } else { createPlanMutation.mutate(payload); } @@ -266,12 +291,39 @@ const Createplan = () => { name="image_url" render={({ field }) => ( -
+

{t("studio.dashboard.cover_image")}

-

- {t("studio.plan.cover_image.description")} + + {/* Tooltip only on desktop */} +

+ + + + + + + +

+ {t("studio.plan.cover_image.constraints")} +

+
+
+
+
+ + {/* ✅ Mobile only */} +

+ {t("studio.plan.cover_image.constraints")}

@@ -328,7 +380,10 @@ const Createplan = () => { Upload & Crop Image - + @@ -382,7 +437,7 @@ const Createplan = () => { { render={({ field }) => ( - + + typeof t === "object" && + t !== null && + "id" in t, + ) + : undefined + } + /> )} />
- {plan_id == "new" ? ( + {isCreateMode ? ( { type="button" variant="outline" className="sm:h-12 sm:px-12 font-medium" - onClick={() => navigate(`/dashboard`)} + onClick={() => navigate(ROUTES.dashboard)} > {t("common.button.cancel")} diff --git a/src/components/routes/create-plan/PlanTagSearchInput.tsx b/src/components/routes/create-plan/PlanTagSearchInput.tsx new file mode 100644 index 00000000..d3ee908e --- /dev/null +++ b/src/components/routes/create-plan/PlanTagSearchInput.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, useState } from "react"; +import { IoMdClose } from "react-icons/io"; +import { useDebounce } from "use-debounce"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/atoms/input"; +import { + createTag, + fetchTags, + type PlanTagSummary, + type Tag, +} from "@/components/routes/tags/api/tagsApi"; + +interface PlanTagSearchInputProps { + value?: string[]; + onChange?: (tagIds: string[]) => void; + initialTags?: PlanTagSummary[]; + planId?: string; +} + +const SUGGESTIONS_LIMIT = 10; +const SEARCH_DEBOUNCE_MS = 400; + +const normalizeName = (name: string) => name.trim().toLowerCase(); + +const PlanTagSearchInput = ({ + value = [], + onChange, + initialTags = [], + planId, +}: PlanTagSearchInputProps) => { + const queryClient = useQueryClient(); + const containerRef = useRef(null); + const [inputValue, setInputValue] = useState(""); + const [debouncedSearch] = useDebounce(inputValue, SEARCH_DEBOUNCE_MS); + const [showSuggestions, setShowSuggestions] = useState(false); + const [tagLabels, setTagLabels] = useState>(() => + Object.fromEntries(initialTags.map((tag) => [tag.id, tag.name])), + ); + + useEffect(() => { + if (!initialTags.length) return; + setTagLabels((prev) => ({ + ...prev, + ...Object.fromEntries(initialTags.map((tag) => [tag.id, tag.name])), + })); + }, [initialTags]); + + const { data, isFetching } = useQuery({ + queryKey: ["cms-tags-search", debouncedSearch], + queryFn: () => fetchTags(1, SUGGESTIONS_LIMIT, debouncedSearch.trim()), + enabled: showSuggestions && debouncedSearch.trim().length > 0, + refetchOnWindowFocus: false, + }); + + const createTagMutation = useMutation({ + mutationFn: (name: string) => + createTag({ + name: name.trim(), + description: null, + image_key: null, + plan_ids: planId ? [planId] : [], + }), + onSuccess: (tag: Tag) => { + queryClient.invalidateQueries({ queryKey: ["cms-tags-search"] }); + queryClient.invalidateQueries({ queryKey: ["cms-tags"] }); + addTag(tag); + setInputValue(""); + setShowSuggestions(false); + toast.success(`Tag "${tag.name}" created`); + }, + onError: () => { + toast.error("Failed to create tag"); + }, + }); + + const suggestions = (data?.tags ?? []).filter( + (tag) => !value.includes(tag.id), + ); + const trimmedInput = inputValue.trim(); + const normalizedInput = normalizeName(trimmedInput); + + const hasExactMatch = suggestions.some( + (tag) => normalizeName(tag.name) === normalizedInput, + ); + const showCreateOption = + trimmedInput.length > 0 && + !hasExactMatch && + !value.some((id) => normalizeName(tagLabels[id] ?? "") === normalizedInput); + + const addTag = (tag: { id: string; name: string }) => { + if (value.includes(tag.id)) return; + setTagLabels((prev) => ({ ...prev, [tag.id]: tag.name })); + onChange?.([...value, tag.id]); + }; + + const removeTag = (tagId: string) => { + onChange?.(value.filter((id) => id !== tagId)); + }; + + const selectSuggestion = (tag: { id: string; name: string }) => { + addTag(tag); + setInputValue(""); + setShowSuggestions(false); + }; + + const handleCreateTag = () => { + if (!trimmedInput || createTagMutation.isPending) return; + createTagMutation.mutate(trimmedInput); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!trimmedInput) return; + + const exactSuggestion = suggestions.find( + (tag) => normalizeName(tag.name) === normalizedInput, + ); + if (exactSuggestion) { + selectSuggestion(exactSuggestion); + return; + } + if (showCreateOption) { + handleCreateTag(); + } + } else if (e.key === "Escape") { + setShowSuggestions(false); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowSuggestions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const showDropdown = + showSuggestions && + trimmedInput.length > 0 && + (isFetching || suggestions.length > 0 || showCreateOption); + + return ( +
+

Tags

+ + {value.length > 0 && ( +
+ {value.map((tagId) => ( +
+

+ {tagLabels[tagId] ?? tagId} +

+ +
+ ))} +
+ )} + +
+ { + setInputValue(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={handleKeyDown} + disabled={createTagMutation.isPending} + autoComplete="off" + aria-autocomplete="list" + aria-expanded={showDropdown} + /> + + {showDropdown && ( +
    + {showCreateOption && ( +
  • + +
  • + )} + {isFetching && suggestions.length === 0 && ( +
  • + Searching... +
  • + )} + {suggestions.map((tag) => ( +
  • + +
  • + ))} + {!isFetching && suggestions.length === 0 && !showCreateOption && ( +
  • + No tags found +
  • + )} +
+ )} +
+
+ ); +}; + +export default PlanTagSearchInput; diff --git a/src/components/routes/dashboard/Dashboard.tsx b/src/components/routes/dashboard/Dashboard.tsx index 418ca04e..8c8380a0 100644 --- a/src/components/routes/dashboard/Dashboard.tsx +++ b/src/components/routes/dashboard/Dashboard.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/atoms/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axiosInstance from "@/config/axios-config"; import { Link } from "react-router-dom"; +import { ROUTES } from "@/routes/paths"; import { Pagination } from "@/components/ui/molecules/pagination/Pagination"; import AuthButton from "@/components/ui/molecules/auth-button/AuthButton"; import { toast } from "sonner"; @@ -120,7 +121,7 @@ const Dashboard = () => { }} />
- + @@ -135,7 +136,7 @@ const Dashboard = () => {

{t("studio.dashboard.no_plan_found")}

- + {t("studio.dashboard.add_plan")} diff --git a/src/components/routes/profile/Profile.test.tsx b/src/components/routes/profile/Profile.test.tsx index 011cb022..84030b06 100644 --- a/src/components/routes/profile/Profile.test.tsx +++ b/src/components/routes/profile/Profile.test.tsx @@ -19,14 +19,6 @@ const mockUserInfo = { ], }; -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); - return { - ...actual, - useParams: vi.fn().mockReturnValue({ author_id: "author-123" }), - }; -}); - vi.mock("@/components/ui/molecules/user-card/UserCard", () => ({ default: ({ userInfo }: any) => (
@@ -38,16 +30,6 @@ vi.mock("@/components/ui/molecules/user-card/UserCard", () => ({ ), })); -Object.defineProperty(window, "sessionStorage", { - value: { - getItem: vi.fn((key) => { - if (key === "accessToken") return "test-token"; - return null; - }), - }, - writable: true, -}); - const renderWithProviders = ( component: React.ReactElement, queryData?: any, @@ -59,7 +41,7 @@ const renderWithProviders = ( }); if (queryData) { - queryClient.setQueryData(["userInfo", "author-123"], queryData); + queryClient.setQueryData(["userInfo"], queryData); } return render( @@ -85,21 +67,14 @@ describe("Profile Component", () => { expect(screen.getByText("Edit")).toBeInTheDocument(); }); - it("fetches user info with correct parameters", async () => { + it("fetches current user info from authors info endpoint", async () => { const { default: axiosInstance } = await import("@/config/axios-config"); vi.mocked(axiosInstance.get).mockResolvedValue({ data: mockUserInfo, }); renderWithProviders(); await waitFor(() => { - expect(axiosInstance.get).toHaveBeenCalledWith( - "/api/v1/authors/author-123", - { - headers: { - Authorization: "Bearer test-token", - }, - }, - ); + expect(axiosInstance.get).toHaveBeenCalledWith("/api/v1/authors/info"); }); }); }); diff --git a/src/components/routes/profile/Profile.tsx b/src/components/routes/profile/Profile.tsx index ac3e0f96..f6a984bf 100644 --- a/src/components/routes/profile/Profile.tsx +++ b/src/components/routes/profile/Profile.tsx @@ -1,29 +1,21 @@ import axiosInstance from "@/config/axios-config"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router-dom"; import { Pecha } from "@/components/ui/shadimport"; import { useState } from "react"; import UserCard from "@/components/ui/molecules/user-card/UserCard"; import ProfileEditForm from "@/components/ui/molecules/profile-edit-form/ProfileEditForm"; -export const fetchUserInfo = async (author_id: string) => { - const accessToken = sessionStorage.getItem("accessToken"); - const { data } = await axiosInstance.get(`/api/v1/authors/${author_id}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); +const fetchUserInfo = async () => { + const { data } = await axiosInstance.get(`/api/v1/authors/info`); return data; }; const Profile = () => { - const { author_id } = useParams(); const [isEditing, setIsEditing] = useState(false); const { data: userInfo, isLoading } = useQuery({ - queryKey: ["userInfo", author_id], - queryFn: () => fetchUserInfo(author_id as string), - enabled: !!author_id, + queryKey: ["userInfo"], + queryFn: fetchUserInfo, }); const handleEdit = () => { @@ -46,10 +38,14 @@ const Profile = () => { ); } + if (!userInfo?.id) { + return null; + } + return (
-
-
+
+

Profile

{!isEditing ? ( @@ -64,11 +60,7 @@ const Profile = () => { {!isEditing ? ( ) : ( - + )}
diff --git a/src/components/routes/tags/TagFormDialog.tsx b/src/components/routes/tags/TagFormDialog.tsx new file mode 100644 index 00000000..06a9d2a2 --- /dev/null +++ b/src/components/routes/tags/TagFormDialog.tsx @@ -0,0 +1,248 @@ +import { useEffect, useState } from "react"; +import { IoMdAdd, IoMdClose } from "react-icons/io"; +import { Pecha } from "@/components/ui/shadimport"; +import { Textarea } from "@/components/ui/atoms/textarea"; +import { Button } from "@/components/ui/atoms/button"; +import ImageContentData from "@/components/ui/molecules/modals/image-upload/ImageContentData"; +import { uploadImageToS3 } from "@/components/routes/task/api/taskApi"; +import { toast } from "sonner"; +import type { PlanOption, Tag, TagPayload } from "./api/tagsApi"; + +interface TagFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tag: Tag | null; + plans: PlanOption[]; + isSubmitting: boolean; + onSubmit: (payload: TagPayload) => void; +} + +const TagFormDialog = ({ + open, + onOpenChange, + tag, + plans, + isSubmitting, + onSubmit, +}: TagFormDialogProps) => { + const isEdit = !!tag; + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [imageKey, setImageKey] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [selectedPlanIds, setSelectedPlanIds] = useState([]); + const [planSearch, setPlanSearch] = useState(""); + const [isImageDialogOpen, setIsImageDialogOpen] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); + + useEffect(() => { + if (!open) return; + setName(tag?.name ?? ""); + setDescription(tag?.description ?? ""); + setImageKey(tag?.image_key ?? null); + setImagePreview(tag?.image ?? null); + setSelectedPlanIds(tag?.plan_ids ?? []); + setPlanSearch(""); + }, [open, tag]); + + const filteredPlans = plans.filter((plan) => + plan.title.toLowerCase().includes(planSearch.toLowerCase()), + ); + + const togglePlan = (planId: string) => { + setSelectedPlanIds((prev) => + prev.includes(planId) + ? prev.filter((id) => id !== planId) + : [...prev, planId], + ); + }; + + const handleImageUpload = async (file: File) => { + setIsImageUploading(true); + try { + const { image, key } = await uploadImageToS3(file, ""); + setImagePreview(image.original); + setImageKey(key); + setIsImageDialogOpen(false); + toast.success("Image uploaded successfully!"); + } catch (error: any) { + if (error?.response?.status === 413) { + toast.error("Failed to upload image", { + description: "File exceeds the maximum size of 1MB", + }); + } else { + toast.error("Failed to upload image"); + } + } finally { + setIsImageUploading(false); + } + }; + + const handleRemoveImage = () => { + setImageKey(null); + setImagePreview(null); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Tag name is required"); + return; + } + onSubmit({ + name: trimmedName, + image_key: imageKey, + description: description.trim() || null, + plan_ids: selectedPlanIds, + }); + }; + + return ( + <> + + + + + {isEdit ? "Edit Tag" : "Create Tag"} + + +
+
+
+ + setName(e.target.value)} + placeholder="Tag name" + required + /> +
+ +
+ +