Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/lms/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions apps/lms/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
14 changes: 14 additions & 0 deletions apps/lms/app/robots.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
6 changes: 6 additions & 0 deletions apps/lms/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { MetadataRoute } from "next";
import { getLmsUrl } from "@/configs/seo";

export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: getLmsUrl("/") }];
}
30 changes: 30 additions & 0 deletions apps/lms/configs/seo.ts
Original file line number Diff line number Diff line change
@@ -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();
}
20 changes: 20 additions & 0 deletions apps/lms/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion apps/lms/proxy.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/sale/app/(guest)/about-us/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const metadata: Metadata = {
locale: "vi_VN",
siteName: "BeeWise",
},
alternates: { canonical: "/about-us" },
};

export default function AboutPage() {
Expand Down
1 change: 1 addition & 0 deletions apps/sale/app/(guest)/favorite-tutors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions apps/sale/app/(guest)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/sale/app/(guest)/tutor-guide/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const metadata: Metadata = {
"nền tảng gia sư",
"BeeWise",
],
alternates: { canonical: "/tutor-guide" },
};

export default function TutorGuidePage() {
Expand Down
1 change: 1 addition & 0 deletions apps/sale/app/(guest)/tutors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const metadata: Metadata = {
"BeeWise",
"tìm gia sư AI",
],
alternates: { canonical: "/tutors" },
};

export default function TutorListPage() {
Expand Down
2 changes: 2 additions & 0 deletions apps/sale/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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ư",
};

Expand Down
14 changes: 14 additions & 0 deletions apps/sale/app/robots.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
8 changes: 8 additions & 0 deletions apps/sale/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
5 changes: 5 additions & 0 deletions apps/sale/app/tutor/profile/edit/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ProfileEditorSkeleton } from "@/features/tutor-profile-editor";

export default function Loading() {
return <ProfileEditorSkeleton />;
}
38 changes: 38 additions & 0 deletions apps/sale/configs/seo.ts
Original file line number Diff line number Diff line change
@@ -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();
}
28 changes: 15 additions & 13 deletions apps/sale/features/favorite-tutors/hooks/useFavoriteTutors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -19,17 +20,7 @@ export function useFavoriteTutorIds() {
queryKey: queryKeys.favoriteTutors.ids,
queryFn: async (): Promise<string[]> => {
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,
Expand Down Expand Up @@ -136,6 +127,16 @@ export function useToggleFavoriteTutorMutation() {
});
},
onSuccess: (_data, variables) => {
queryClient.setQueryData<string[]>(
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ư",
Expand All @@ -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"],
});
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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 : [];
}
2 changes: 1 addition & 1 deletion apps/sale/features/messages/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
</span>
</div>

Expand Down
7 changes: 5 additions & 2 deletions apps/sale/features/messages/components/MessagesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ export function MessagesScreen() {
</div>
<span
className="text-xl tracking-tight text-[#280f91]"
style={{ fontFamily: "var(--font-nunito-family)", fontWeight: 800 }}
style={{
fontFamily: "var(--font-nunito-family)",
fontWeight: 800,
}}
>
CỘNG ĐỒNG GIA SƯ
CỘNG ĐỒNG GIA SƯ BEEWISE
</span>
</div>
<h2 className="mb-2 text-lg font-extrabold text-[#0c0c0b]">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 });
Expand All @@ -40,11 +41,12 @@ export function TutorApprovedPreview({ scenario, capture }: { scenario?: string
return (
<TutorApprovedProvider scenario={parsed}>
<TutorApprovedShell
screen={screen}
previewUser={previewUser}
capture={capture}
toolbar={!capture ? <div className="fixed left-1/2 top-18 z-90 flex -translate-x-1/2 flex-wrap items-center justify-center gap-2 rounded-full border bg-white/95 px-3 py-2 shadow-lg backdrop-blur"><span className="text-xs font-bold text-[#905b0f]">PREVIEW</span><label className="sr-only" htmlFor="approved-scenario">Scenario</label><select id="approved-scenario" value={parsed} onChange={(e)=>navigate(e.target.value as TutorApprovedScenario,false)} className="rounded-full border px-3 py-1 text-xs">{tutorApprovedScenarios.map((item: string)=><option key={item}>{item}</option>)}</select><button onClick={()=>navigate(parsed,true)} className="text-xs font-bold text-[#280f91]">Capture mode</button></div> : null}
>
<TutorApprovedScreenView screen={scenarioScreen(parsed)} />
<TutorApprovedScreenView screen={screen} />
</TutorApprovedShell>
</TutorApprovedProvider>
);
Expand Down
Loading
Loading