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
4 changes: 1 addition & 3 deletions apps/lms/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { NextConfig } from "next";

const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.NEXT_PUBLIC_API_URL ||
"https://api.beewise.vn";
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL;

const nextConfig: NextConfig = {
transpilePackages: ["@workspace/ui", "@workspace/core"],
Expand Down
5 changes: 2 additions & 3 deletions apps/sale/app/(guest)/tutors/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Footer } from '@workspace/ui/components/layout/Footer';
import { Header } from '@workspace/ui/components/layout/Header';
import { TutorProfilePage } from '@/features/tutor-profile/components/TutorProfilePage';

export const metadata: Metadata = {
Expand All @@ -12,11 +11,11 @@ export const metadata: Metadata = {
export default function TutorProfileRoutePage() {
return (
<div className="flex min-h-screen flex-col">
<Header />
<main id="main-content" className="flex-1 pt-16">
<main id="main-content" className="flex-1">
<TutorProfilePage />
</main>
<Footer />
</div>
);
}

2 changes: 2 additions & 0 deletions apps/sale/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { MessageFAB } from "@/features/messages";
import { saleSeoConfig } from "@/configs/seo";

export const metadata: Metadata = {
Expand All @@ -27,6 +28,7 @@ export default function RootLayout({
{children}
<GuestFavoriteModal />
<NotificationDrawer />
<MessageFAB />
</Providers>
</body>
</html>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ export function GuestFavoriteModal() {
{/* Heart icon with badge */}
<div className="relative mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 backdrop-blur-md border border-white/25 shadow-lg shadow-black/10">
<Heart className="h-8 w-8 text-[#ffc500] fill-[#ffc500]" />
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-[#ffc500] text-[#280f91]">
<Sparkles className="h-3.5 w-3.5" />
</div>
</div>

<DialogHeader className="space-y-2 text-center sm:text-center">
Expand Down
60 changes: 60 additions & 0 deletions apps/sale/features/messages/components/MessageFAB.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuthStore } from "@workspace/core/store/useAuthStore";

/**
* Floating message button shown at the bottom-right of the screen.
* Only visible for authenticated LEARNER users.
* Excluded on the messages page itself.
*/
export function MessageFAB() {
const pathname = usePathname();
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isAuthLoading = useAuthStore((state) => state.isAuthLoading);

const unreadChatCount = Math.max(0, user?.unreadChatCount ?? 0);
const isLearner = user?.role?.trim().toUpperCase() === "LEARNER";

// Hide when: loading, unauthenticated, not learner, or already on messages page
const isOnMessagesPage =
pathname?.startsWith("/learner/messages") ?? false;

if (isAuthLoading || !isAuthenticated || !isLearner || isOnMessagesPage) {
return null;
}

return (
<Link
href="/learner/messages"
aria-label="Tin nhắn"
className="fixed bottom-6 right-6 z-50 flex h-16 w-16 items-center justify-center rounded-full shadow-2xl shadow-primary/30 transition-transform duration-200 hover:scale-110 active:scale-95 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent"
style={{
background: "linear-gradient(135deg, #280f91 0%, #3d1fd4 100%)",
}}
>
{/* Icon */}
<Image
src="/icons/beewise-message-icon.svg"
alt="Tin nhắn"
width={36}
height={36}
className="object-contain"
priority
/>

{/* Unread badge */}
{unreadChatCount > 0 && (
<span
aria-label={`${unreadChatCount} tin nhắn chưa đọc`}
className="absolute -right-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-accent px-1.5 py-0.5 text-center text-[10px] font-bold leading-5 text-accent-foreground"
>
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
)}
</Link>
);
}
1 change: 1 addition & 0 deletions apps/sale/features/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./components/ChatRoomScreen";
export * from "./components/ChatRoomPanel";
export * from "./components/ChatSidebar";
export * from "./components/LearnerMessagesRoute";
export * from "./components/MessageFAB";

export * from "./hooks/useMessages";
export * from "./hooks/useChatRoom";
Expand Down
19 changes: 15 additions & 4 deletions apps/sale/features/tutor-list/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function SearchBar({
? "bg-background text-foreground shadow-sm border border-border"
: "text-foreground/50 hover:text-foreground/80"
}`}
style={{ fontFamily: "var(--font-nunito-family)" }}
style={{ fontFamily: "var(--font-google-sans)" }}
aria-selected={!isAI}
role="tab"
id="search-mode-manual"
Expand All @@ -73,7 +73,7 @@ export function SearchBar({
? "bg-primary text-primary-foreground shadow-sm"
: "text-foreground/50 hover:text-foreground/80"
}`}
style={{ fontFamily: "var(--font-nunito-family)" }}
style={{ fontFamily: "var(--font-google-sans)" }}
aria-selected={isAI}
role="tab"
id="search-mode-ai"
Expand Down Expand Up @@ -162,8 +162,19 @@ export function SearchBar({
aria-label={isAI ? "Tìm với AI" : "Tìm kiếm"}
>
{isLoading && isAI ? (
<><CircleNotchIcon className="animate-spin" size={14} aria-hidden="true" />Đang tìm</>
) : isAI ? <>Tìm với AI</> : <>Tìm ngay</>}
<>
<CircleNotchIcon
className="animate-spin"
size={14}
aria-hidden="true"
/>
Đang tìm
</>
) : isAI ? (
<>Tìm với AI</>
) : (
<>Tìm ngay</>
)}
</button>
</div>
</div>
Expand Down
7 changes: 3 additions & 4 deletions apps/sale/features/tutor-list/hooks/useTutorSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const mapFiltersToManualQuery = (
const manualQuery: ManualSearchQuery = {
keyword: query || undefined,
page,
pageSize: 50,
pageSize: 6,
};

if (filters.teachingMode !== "all") {
Expand Down Expand Up @@ -134,9 +134,8 @@ export function useTutorSearch() {
const initialQuery = searchParams.get("q");

const [searchMode, setSearchMode] = useState<SearchMode>(initialMode);
const [filtersByMode, setFiltersByMode] = useState<
Record<SearchMode, TutorFilters>
>(cachedFiltersByMode);
const [filtersByMode, setFiltersByMode] =
useState<Record<SearchMode, TutorFilters>>(cachedFiltersByMode);
const [queries, setQueries] = useState<Record<SearchMode, string>>(() =>
initialQuery === null
? cachedQueries
Expand Down
16 changes: 8 additions & 8 deletions apps/sale/features/tutor-onboarding/api/tutor-onboarding.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@ export function applyTutorOnboardingAction(
selectedStepId: payload?.stepId ?? state.selectedStepId,
};
case "submit-profile":
// Submit goes directly to interview (no listing-waived step)
return {
...state,
scenario: "listing-waived",
selectedStepId: "listing",
lastActionMessage: "Hồ sơ mock đã được gửi.",
scenario: "interview",
selectedStepId: "interview",
lastActionMessage: "Hồ sơ đã được gửi thành công.",
};
case "continue-to-interview":
return { ...state, scenario: "interview", selectedStepId: "interview" };
case "complete-mock-interview":
return {
...state,
Expand All @@ -35,7 +34,7 @@ export function applyTutorOnboardingAction(
...state,
scenario: "approved",
selectedStepId: "postApproval",
lastActionMessage: "Hồ sơ mock đã được duyệt.",
lastActionMessage: "Hồ sơ đã được phê duyệt.",
};
case "edit-rejected-profile":
return {
Expand All @@ -49,7 +48,7 @@ export function applyTutorOnboardingAction(
...state,
scenario: "pending-review",
selectedStepId: "verification",
lastActionMessage: "Hồ sơ mock đã được gửi lại.",
lastActionMessage: "Hồ sơ đã được gửi lại thành công.",
};
case "open-post-approval-form":
return {
Expand All @@ -68,9 +67,10 @@ export function applyTutorOnboardingAction(
case "open-lms-preview":
return {
...state,
lastActionMessage: "Thao tác preview đã được ghi nhận trong bộ nhớ.",
lastActionMessage: "Thao tác đã được ghi nhận.",
};
default:
return state;
}
}

123 changes: 123 additions & 0 deletions apps/sale/features/tutor-onboarding/api/vietqr.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export interface VietQRBank {
id: number;
name: string;
code: string;
bin: string;
shortName: string;
logo: string;
transferSupported?: number;
lookupSupported?: number;
short_name?: string;
support?: number;
isTransfer?: number;
swift_code?: string;
}

export interface VietQRLookupResult {
success: boolean;
accountName?: string;
error?: string;
isKeyMissing?: boolean;
}

let cachedBanks: VietQRBank[] | null = null;

/**
* Fetch list of banks supported by VietQR
* GET https://api.vietqr.io/v2/banks
*/
export async function getVietQRBanks(): Promise<VietQRBank[]> {
if (cachedBanks && cachedBanks.length > 0) {
return cachedBanks;
}

try {
const res = await fetch("https://api.vietqr.io/v2/banks", {
method: "GET",
headers: {
Accept: "application/json",
},
});

if (!res.ok) {
throw new Error(`Failed to fetch banks list: ${res.status}`);
}

const data = await res.json();
if (data.code === "00" && Array.isArray(data.data)) {
cachedBanks = data.data;
return data.data;
}

return [];
} catch (error) {
console.error("Error fetching VietQR banks:", error);
return [];
}
}

/**
* Lookup bank account holder name via VietQR API
* POST https://api.vietqr.io/v2/lookup
*/
export async function lookupVietQRAccount(
bin: string,
accountNumber: string,
): Promise<VietQRLookupResult> {
const cleanAccount = accountNumber.trim().replace(/\s+/g, "");
const cleanBin = bin.trim();

if (!cleanBin || !cleanAccount) {
return { success: false, error: "Vui lòng chọn ngân hàng và nhập số tài khoản" };
}

const clientId = process.env.NEXT_PUBLIC_VIETQR_CLIENT_ID || "";
const apiKey = process.env.NEXT_PUBLIC_VIETQR_API_KEY || "";

try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};

if (clientId) headers["x-client-id"] = clientId;
if (apiKey) headers["x-api-key"] = apiKey;

const res = await fetch("https://api.vietqr.io/v2/lookup", {
method: "POST",
headers,
body: JSON.stringify({
bin: cleanBin,
accountNumber: cleanAccount,
}),
});

const data = await res.json();

if (res.status === 401 || data.code === "401") {
return {
success: false,
error: "VietQR yêu cầu x-client-id và x-api-key để tra cứu tài khoản thực tế.",
isKeyMissing: true,
};
}

if (data.code === "00" && data.data?.accountName) {
return {
success: true,
accountName: data.data.accountName,
};
}

return {
success: false,
error: data.desc || "Không tìm thấy thông tin tài khoản hoặc số tài khoản không hợp lệ.",
};
} catch (error) {
console.error("Error looking up VietQR account:", error);
return {
success: false,
error: "Không thể kết nối đến hệ thống tra cứu ngân hàng.",
};
}
}
Loading
Loading