diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index bad7746..86f4d82 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -20,6 +20,9 @@ jobs: node-version: '20' cache: 'npm' + - name: Use project npm version + run: npm install --global npm@11.6.1 + - name: Install dependencies run: npm ci diff --git a/apps/lms/app/(home)/page.tsx b/apps/lms/app/(home)/page.tsx index 4df5e77..c5b1c7a 100644 --- a/apps/lms/app/(home)/page.tsx +++ b/apps/lms/app/(home)/page.tsx @@ -10,6 +10,8 @@ export const metadata: Metadata = { title: "BeeWise LMS | Hệ Thống Quản Lý Học Tập Thông Minh", description: "Không gian làm việc và học tập của Gia sư và Học viên. Số hóa lịch trình, lưu trữ tài liệu và tối ưu hóa hiệu quả dạy & học trên BeeWise LMS.", + alternates: { canonical: "/" }, + robots: { index: true, follow: true }, }; export default function LmsHomePage() { diff --git a/apps/lms/app/layout.tsx b/apps/lms/app/layout.tsx index 3e16022..200a7fd 100644 --- a/apps/lms/app/layout.tsx +++ b/apps/lms/app/layout.tsx @@ -3,8 +3,10 @@ import "@workspace/ui/globals.css"; import { Metadata } from "next"; import { googleSans, nunito } from "@workspace/core/configs/fonts"; import { TooltipProvider } from "@workspace/ui/components/ui/tooltip"; +import { lmsSeoConfig } from "@/configs/seo"; export const metadata: Metadata = { + metadataBase: new URL(lmsSeoConfig.siteUrl), title: "BeeWise LMS | Hệ Thống Quản Lý Học Tập Thông Minh", }; diff --git a/apps/lms/app/robots.ts b/apps/lms/app/robots.ts new file mode 100644 index 0000000..b6db78a --- /dev/null +++ b/apps/lms/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from "next"; +import { getLmsUrl, lmsSeoConfig } from "@/configs/seo"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: ["/", "/login"], + disallow: "/lms/", + }, + sitemap: getLmsUrl("/sitemap.xml"), + host: lmsSeoConfig.siteUrl, + }; +} diff --git a/apps/lms/app/sitemap.ts b/apps/lms/app/sitemap.ts new file mode 100644 index 0000000..78beaa2 --- /dev/null +++ b/apps/lms/app/sitemap.ts @@ -0,0 +1,6 @@ +import type { MetadataRoute } from "next"; +import { getLmsUrl } from "@/configs/seo"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [{ url: getLmsUrl("/") }]; +} diff --git a/apps/lms/configs/seo.ts b/apps/lms/configs/seo.ts new file mode 100644 index 0000000..57b708b --- /dev/null +++ b/apps/lms/configs/seo.ts @@ -0,0 +1,30 @@ +const DEFAULT_LMS_SITE_URL = "https://superlms.beewise.vn"; + +function resolveSiteUrl(value: string | undefined, fallback: string): string { + if (!value) { + return fallback; + } + + try { + const url = new URL(value); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return fallback; + } + + return url.origin; + } catch { + return fallback; + } +} + +export const lmsSeoConfig = { + siteUrl: resolveSiteUrl( + process.env.NEXT_PUBLIC_SITE_URL, + DEFAULT_LMS_SITE_URL, + ), +} as const; + +export function getLmsUrl(pathname: string): string { + return new URL(pathname, `${lmsSeoConfig.siteUrl}/`).toString(); +} diff --git a/apps/lms/next.config.ts b/apps/lms/next.config.ts index a9bd400..b57985b 100644 --- a/apps/lms/next.config.ts +++ b/apps/lms/next.config.ts @@ -17,6 +17,26 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "res.cloudinary.com", }, + { + protocol: "https", + hostname: "demo.invalid", + }, + { + protocol: "https", + hostname: "images.unsplash.com", + }, + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + { + protocol: "https", + hostname: "api.dicebear.com", + }, ], }, async rewrites() { diff --git a/apps/lms/proxy.ts b/apps/lms/proxy.ts index 9ddc43a..e428333 100644 --- a/apps/lms/proxy.ts +++ b/apps/lms/proxy.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -const publicPaths = ["/", "/login"]; +const publicPaths = ["/", "/login", "/robots.txt", "/sitemap.xml"]; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; diff --git a/apps/sale/app/(guest)/about-us/page.tsx b/apps/sale/app/(guest)/about-us/page.tsx index e322214..e1ee8a4 100644 --- a/apps/sale/app/(guest)/about-us/page.tsx +++ b/apps/sale/app/(guest)/about-us/page.tsx @@ -27,6 +27,7 @@ export const metadata: Metadata = { locale: "vi_VN", siteName: "BeeWise", }, + alternates: { canonical: "/about-us" }, }; export default function AboutPage() { diff --git a/apps/sale/app/(guest)/favorite-tutors/page.tsx b/apps/sale/app/(guest)/favorite-tutors/page.tsx index 4599a0b..1fdf4b5 100644 --- a/apps/sale/app/(guest)/favorite-tutors/page.tsx +++ b/apps/sale/app/(guest)/favorite-tutors/page.tsx @@ -4,6 +4,7 @@ import { FavoriteTutorsPage } from "../../../features/favorite-tutors/components export const metadata: Metadata = { title: "Gia sư yêu thích | BeeWise", description: "Danh sách các gia sư bạn đã lưu lại trên hệ thống BeeWise.", + robots: { index: false, follow: false }, }; import { Header } from "@workspace/ui/components/layout/Header"; diff --git a/apps/sale/app/(guest)/page.tsx b/apps/sale/app/(guest)/page.tsx index f9fa728..8ab7beb 100644 --- a/apps/sale/app/(guest)/page.tsx +++ b/apps/sale/app/(guest)/page.tsx @@ -39,6 +39,7 @@ export const metadata: Metadata = { description: "Tìm gia sư phù hợp trong 30 giây với AI. Hồ sơ xác thực, cố vấn đồng hành, miễn phí.", }, + alternates: { canonical: "/" }, robots: { index: true, follow: true, diff --git a/apps/sale/app/(guest)/tutor-guide/page.tsx b/apps/sale/app/(guest)/tutor-guide/page.tsx index f09ee37..fd39c6a 100644 --- a/apps/sale/app/(guest)/tutor-guide/page.tsx +++ b/apps/sale/app/(guest)/tutor-guide/page.tsx @@ -23,6 +23,7 @@ export const metadata: Metadata = { "nền tảng gia sư", "BeeWise", ], + alternates: { canonical: "/tutor-guide" }, }; export default function TutorGuidePage() { diff --git a/apps/sale/app/(guest)/tutors/page.tsx b/apps/sale/app/(guest)/tutors/page.tsx index 629c896..267816b 100644 --- a/apps/sale/app/(guest)/tutors/page.tsx +++ b/apps/sale/app/(guest)/tutors/page.tsx @@ -18,6 +18,7 @@ export const metadata: Metadata = { "BeeWise", "tìm gia sư AI", ], + alternates: { canonical: "/tutors" }, }; export default function TutorListPage() { diff --git a/apps/sale/app/layout.tsx b/apps/sale/app/layout.tsx index 17ceea3..4322fba 100644 --- a/apps/sale/app/layout.tsx +++ b/apps/sale/app/layout.tsx @@ -4,8 +4,10 @@ import "@workspace/ui/globals.css"; import { Metadata } from "next"; import { GuestFavoriteModal } from "@/features/favorite-tutors/components/GuestFavoriteModal"; import { NotificationDrawer } from "@/features/notifications/components/NotificationDrawer"; +import { saleSeoConfig } from "@/configs/seo"; export const metadata: Metadata = { + metadataBase: new URL(saleSeoConfig.siteUrl), title: "Cộng đồng gia sư", }; diff --git a/apps/sale/app/robots.ts b/apps/sale/app/robots.ts new file mode 100644 index 0000000..41aee19 --- /dev/null +++ b/apps/sale/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from "next"; +import { getSaleUrl, saleSeoConfig } from "@/configs/seo"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: [...saleSeoConfig.privateRoutes], + }, + sitemap: getSaleUrl("/sitemap.xml"), + host: saleSeoConfig.siteUrl, + }; +} diff --git a/apps/sale/app/sitemap.ts b/apps/sale/app/sitemap.ts new file mode 100644 index 0000000..120c326 --- /dev/null +++ b/apps/sale/app/sitemap.ts @@ -0,0 +1,8 @@ +import type { MetadataRoute } from "next"; +import { getSaleUrl, saleSeoConfig } from "@/configs/seo"; + +export default function sitemap(): MetadataRoute.Sitemap { + return saleSeoConfig.indexableRoutes.map((route) => ({ + url: getSaleUrl(route), + })); +} diff --git a/apps/sale/app/tutor/profile/edit/loading.tsx b/apps/sale/app/tutor/profile/edit/loading.tsx new file mode 100644 index 0000000..b475b4b --- /dev/null +++ b/apps/sale/app/tutor/profile/edit/loading.tsx @@ -0,0 +1,5 @@ +import { ProfileEditorSkeleton } from "@/features/tutor-profile-editor"; + +export default function Loading() { + return ; +} diff --git a/apps/sale/configs/seo.ts b/apps/sale/configs/seo.ts new file mode 100644 index 0000000..f43abe2 --- /dev/null +++ b/apps/sale/configs/seo.ts @@ -0,0 +1,38 @@ +const DEFAULT_SALE_SITE_URL = "https://beewise.vn"; + +function resolveSiteUrl(value: string | undefined, fallback: string): string { + if (!value) { + return fallback; + } + + try { + const url = new URL(value); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return fallback; + } + + return url.origin; + } catch { + return fallback; + } +} + +export const saleSeoConfig = { + siteUrl: resolveSiteUrl( + process.env.NEXT_PUBLIC_SITE_URL, + DEFAULT_SALE_SITE_URL, + ), + indexableRoutes: ["/", "/tutors", "/tutor-guide", "/about-us"], + privateRoutes: [ + "/learner/", + "/tutor/", + "/community/applications", + "/community/my-posts", + "/dev/", + ], +} as const; + +export function getSaleUrl(pathname: string): string { + return new URL(pathname, `${saleSeoConfig.siteUrl}/`).toString(); +} diff --git a/apps/sale/features/favorite-tutors/hooks/useFavoriteTutors.ts b/apps/sale/features/favorite-tutors/hooks/useFavoriteTutors.ts index f57cc05..cb0749e 100644 --- a/apps/sale/features/favorite-tutors/hooks/useFavoriteTutors.ts +++ b/apps/sale/features/favorite-tutors/hooks/useFavoriteTutors.ts @@ -8,6 +8,7 @@ import { useToastStore } from "@workspace/ui/components/ui/bee-toast/useToastSto import { useFavoriteAuthModalStore } from "../store/useFavoriteAuthModalStore"; import type { ApiTutorProfile } from "../../tutor-list/data/types"; import type { FavoriteTutorsListParams } from "@workspace/core/types/favorite-tutors.type"; +import { parseFavoriteTutorIds } from "../schemas/favorite-tutor-ids.schema"; /** * Hook to query all favorite tutor IDs for quick lookup (heart active/inactive state). @@ -19,17 +20,7 @@ export function useFavoriteTutorIds() { queryKey: queryKeys.favoriteTutors.ids, queryFn: async (): Promise => { const response = await favoriteTutorsService.getFavoriteTutorIds(); - const rawData = response?.data; - - if (Array.isArray(rawData)) { - return rawData; - } - - if (rawData && typeof rawData === "object" && "ids" in rawData && Array.isArray((rawData as { ids: string[] }).ids)) { - return (rawData as { ids: string[] }).ids; - } - - return []; + return parseFavoriteTutorIds(response?.data); }, enabled: isAuthenticated, staleTime: 5 * 60 * 1000, @@ -136,6 +127,16 @@ export function useToggleFavoriteTutorMutation() { }); }, onSuccess: (_data, variables) => { + queryClient.setQueryData( + queryKeys.favoriteTutors.ids, + (currentIds = []) => + variables.isCurrentlySaved + ? currentIds.filter((id) => id !== variables.tutorId) + : currentIds.includes(variables.tutorId) + ? currentIds + : [...currentIds, variables.tutorId], + ); + if (variables.isCurrentlySaved) { addToast({ title: "Đã bỏ lưu gia sư", @@ -155,8 +156,9 @@ export function useToggleFavoriteTutorMutation() { } }, onSettled: () => { - // Invalidate queries to sync with backend - queryClient.invalidateQueries({ queryKey: queryKeys.favoriteTutors.all }); + queryClient.invalidateQueries({ + queryKey: [...queryKeys.favoriteTutors.all, "list"], + }); }, }); diff --git a/apps/sale/features/favorite-tutors/schemas/favorite-tutor-ids.schema.ts b/apps/sale/features/favorite-tutors/schemas/favorite-tutor-ids.schema.ts new file mode 100644 index 0000000..1947f84 --- /dev/null +++ b/apps/sale/features/favorite-tutors/schemas/favorite-tutor-ids.schema.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +const favoriteTutorIdValueSchema = z + .union([z.string(), z.number()]) + .transform((value) => String(value)); + +const favoriteTutorIdItemSchema = z.union([ + favoriteTutorIdValueSchema, + z + .object({ + tutorProfileId: favoriteTutorIdValueSchema.optional(), + profileId: favoriteTutorIdValueSchema.optional(), + id: favoriteTutorIdValueSchema.optional(), + }) + .transform((value) => value.tutorProfileId ?? value.profileId ?? value.id), +]); + +const favoriteTutorIdsArraySchema = z + .array(favoriteTutorIdItemSchema) + .transform((items) => [ + ...new Set(items.filter((item): item is string => Boolean(item))), + ]); + +const favoriteTutorIdsPayloadSchema = z + .union([ + favoriteTutorIdsArraySchema, + z.object({ ids: favoriteTutorIdsArraySchema }), + z.object({ tutorProfileIds: favoriteTutorIdsArraySchema }), + z.object({ favoriteTutorIds: favoriteTutorIdsArraySchema }), + ]) + .transform((payload) => { + if (Array.isArray(payload)) return payload; + if ("ids" in payload) return payload.ids; + if ("tutorProfileIds" in payload) return payload.tutorProfileIds; + return payload.favoriteTutorIds; + }); + +export function parseFavoriteTutorIds(payload: unknown): string[] { + const result = favoriteTutorIdsPayloadSchema.safeParse(payload); + return result.success ? result.data : []; +} diff --git a/apps/sale/features/messages/components/ChatSidebar.tsx b/apps/sale/features/messages/components/ChatSidebar.tsx index 640796e..1c3593d 100644 --- a/apps/sale/features/messages/components/ChatSidebar.tsx +++ b/apps/sale/features/messages/components/ChatSidebar.tsx @@ -126,7 +126,7 @@ export function ChatSidebar() { className="text-sm tracking-tight text-[#280f91]" style={{ fontFamily: "var(--font-nunito-family)", fontWeight: 800 }} > - CỘNG ĐỒNG GIA SƯ + CỘNG ĐỒNG GIA SƯ BEEWISE diff --git a/apps/sale/features/messages/components/MessagesScreen.tsx b/apps/sale/features/messages/components/MessagesScreen.tsx index 8db88fb..a494977 100644 --- a/apps/sale/features/messages/components/MessagesScreen.tsx +++ b/apps/sale/features/messages/components/MessagesScreen.tsx @@ -22,9 +22,12 @@ export function MessagesScreen() { - CỘNG ĐỒNG GIA SƯ + CỘNG ĐỒNG GIA SƯ BEEWISE

diff --git a/apps/sale/features/tutor-approved/components/TutorApprovedPreview.tsx b/apps/sale/features/tutor-approved/components/TutorApprovedPreview.tsx index c977977..13b34c3 100644 --- a/apps/sale/features/tutor-approved/components/TutorApprovedPreview.tsx +++ b/apps/sale/features/tutor-approved/components/TutorApprovedPreview.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { TutorApprovedProvider } from "./TutorApprovedProvider"; import { TutorApprovedScreenView, TutorApprovedShell } from "./tutor-approved.ui"; -import { resolveTutorApprovedScenario, parseTutorApprovedScenario } from "../schemas/tutor-approved.resolver"; +import { parseTutorApprovedScenario } from "../schemas/tutor-approved.resolver"; import { tutorApprovedScenarios, type TutorApprovedScenario, type TutorApprovedScreen } from "../types"; const previewUser = { @@ -31,6 +31,7 @@ function scenarioScreen(scenario: TutorApprovedScenario): TutorApprovedScreen { export function TutorApprovedPreview({ scenario, capture }: { scenario?: string | null; capture: boolean }) { const parsed = parseTutorApprovedScenario(scenario); + const screen = scenarioScreen(parsed); const router = useRouter(); const navigate = (next: TutorApprovedScenario, nextCapture = capture) => { const params = new URLSearchParams({ scenario: next }); @@ -40,11 +41,12 @@ export function TutorApprovedPreview({ scenario, capture }: { scenario?: string return ( PREVIEW : null} > - + ); diff --git a/apps/sale/features/tutor-approved/components/tutor-approved.ui.tsx b/apps/sale/features/tutor-approved/components/tutor-approved.ui.tsx index 1cd578a..c34d618 100644 --- a/apps/sale/features/tutor-approved/components/tutor-approved.ui.tsx +++ b/apps/sale/features/tutor-approved/components/tutor-approved.ui.tsx @@ -26,6 +26,8 @@ import { TUTOR_LMS_URL } from "@workspace/core/constants/tutor-links"; import { useTutorApproved } from "./TutorApprovedProvider"; import type { TutorApprovedScreen } from "../types"; import { ChatSummary, MessagesScreen, ChatRoomScreen } from "../../messages"; +import { TutorProfileEditorScreen } from "../../tutor-profile-editor"; +import { TutorOwnProfileScreen } from "../../tutor-profile/components/TutorOwnProfileScreen"; const card = "rounded-2xl border border-[#dce7f7] bg-white p-5 shadow-[0_12px_32px_rgba(40,15,145,0.07)]"; @@ -46,6 +48,15 @@ export function TutorApprovedShell({ screen?: TutorApprovedScreen; }) { const isChat = screen === "messages" || screen === "chat-room"; + const isProfileEdit = screen === "profile-edit"; + + if (isProfileEdit) { + return ( +
+ {children} +
+ ); + } if (isChat) { return ( @@ -84,8 +95,8 @@ export function TutorApprovedScreenView({ screen?: TutorApprovedScreen; chatRoomId?: string; }) { - if (screen === "profile" || screen === "profile-edit") - return ; + if (screen === "profile-edit") return ; + if (screen === "profile") return ; if (screen === "messages") return ; if (screen === "chat-room") return ; if (screen === "notifications") return ; @@ -476,130 +487,6 @@ function RecentActivity() { ); } -function ProfileScreen({ edit }: { edit: boolean }) { - const { state, dispatch } = useTutorApproved(); - const p = edit ? state.draftProfile : state.profile; - const fields: - | [[keyof typeof p, string, boolean]] - | Array<[keyof typeof p, string, boolean]> = [ - ["title", "Tiêu đề hồ sơ", true], - ["introduction", "Giới thiệu ngắn", true], - ["description", "Mô tả", true], - ["teachingMethod", "Phương pháp giảng dạy", false], - ["experience", "Kinh nghiệm", false], - ["rate", "Học phí dự kiến", true], - ]; - return ( -
-
-

Hồ sơ gia sư

-

- {edit ? "Chỉnh sửa hồ sơ" : "Hồ sơ của tôi"} -

-

- Thông tin ngân hàng và giấy tờ xác thực không xuất hiện trong hồ sơ - công khai. -

-
- {state.profileSubmitted && ( -
- Thay đổi đã được gửi. Hồ sơ công khai hiện tại vẫn được giữ cho đến - khi Consultant phê duyệt nội dung mới. -
- )} -
-
-
- MA -
-
-

{p.fullName}

- - Đã xác thực - -
-
- {edit ? ( -
{ - e.preventDefault(); - dispatch({ type: "submit-profile" }); - }} - className="space-y-4" - > - {fields.map(([field, label, review]) => ( -