{t("settings.allowSavePosts")}
diff --git a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
index b62cc8b..9938a38 100644
--- a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
+++ b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
@@ -11,6 +11,8 @@ import useAxios from "@/hooks/useAxios";
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
import { cn } from "@/lib/utils";
import { IShopProductListing, IShopProductVariant } from "@/types/types";
+import Cookies from "js-cookie";
+import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
@@ -80,6 +82,24 @@ export default function ListingDetailClient() {
const shopInfo: ShopHeader | null =
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
+ useEffect(() => {
+ if (!listing?._id) return;
+ const token = Cookies.get("token") || "";
+ if (!token) return;
+ const ownerId = shopInfo?._id || (typeof listing.shop === "string" ? listing.shop : undefined);
+ trackExploreInteraction(
+ {
+ targetType: "shop_product",
+ targetId: listing._id,
+ authorId: ownerId,
+ contentType: "shop_product",
+ action: "view",
+ },
+ token
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [listing?._id]);
+
const colors = useMemo(
() => uniqueValues(listing?.variants.map((v) => v.color) || []),
[listing]
diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx
index 3636153..05a3b07 100644
--- a/src/components/TabNavigation.tsx
+++ b/src/components/TabNavigation.tsx
@@ -1,98 +1,87 @@
-"use client";
-
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import BoldIcon from "@/components/ui/BoldIcon";
-import { cn } from "@/lib/utils";
-import { PAGE_SHELL_CLASS } from "@/constants/pageLayout";
-import { readLocalStorage } from "@/lib/safeStorage";
-import { useTranslation } from "react-i18next";
-
-const TAB_KEYS = [
- { href: "/", icon: "home-2", labelKey: "tabs.home" },
- { href: "/academy", icon: "teacher", labelKey: "tabs.academy" },
- { href: "/projects", icon: "briefcase", labelKey: "tabs.projects" },
- { href: "/billboards", icon: "flash-circle", labelKey: "tabs.billboards" },
- { href: "/settings", icon: "setting", labelKey: "tabs.settings" },
-] as const;
-
-type TabNavigationProps = {
- currentPage: string;
-};
-
-export default function TabNavigation({ currentPage }: TabNavigationProps) {
- const { t } = useTranslation("common");
- const [mounted, setMounted] = useState(false);
- const [isDark, setIsDark] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- useEffect(() => {
- if (typeof window === "undefined") return;
- const checkTheme = () => {
- const theme = readLocalStorage("theme");
- const isDarkMode =
- theme === "dark" ||
- (theme === "system" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches);
- setIsDark(isDarkMode);
- };
- checkTheme();
- window.addEventListener("storage", checkTheme);
- return () => window.removeEventListener("storage", checkTheme);
- }, []);
-
- if (!mounted) return null;
-
- return (
-
- );
-}
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { cn } from "@/lib/utils";
+import { PAGE_SHELL_CLASS } from "@/constants/pageLayout";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@/contexts/ThemeContext";
+import { glassBackdropStyle } from "@/lib/glassBackdrop";
+
+const TAB_KEYS = [
+ { href: "/", icon: "home-2", labelKey: "tabs.home" },
+ { href: "/academy", icon: "teacher", labelKey: "tabs.academy" },
+ { href: "/projects", icon: "briefcase", labelKey: "tabs.projects" },
+ { href: "/billboards", icon: "flash-circle", labelKey: "tabs.billboards" },
+ { href: "/settings", icon: "setting", labelKey: "tabs.settings" },
+] as const;
+
+type TabNavigationProps = {
+ currentPage: string;
+};
+
+export default function TabNavigation({ currentPage }: TabNavigationProps) {
+ const { t } = useTranslation("common");
+ const { isDark } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) return null;
+
+ return (
+
+ );
+}
diff --git a/src/components/academy/CourseCommentsContent.tsx b/src/components/academy/CourseCommentsContent.tsx
index 055b7d5..d4bf1b4 100644
--- a/src/components/academy/CourseCommentsContent.tsx
+++ b/src/components/academy/CourseCommentsContent.tsx
@@ -25,6 +25,7 @@ export interface CourseCommentUser {
user_name: string;
profile_image: string | null;
is_verified: string;
+ verify_badge?: string;
}
export interface CourseComment {
@@ -85,6 +86,8 @@ function CommentRow({
size="chat"
rounded="full"
className="shrink-0"
+ userId={item.user?._id}
+ verifyBadge={item.user?.verify_badge}
/>
diff --git a/src/components/academy/MainModelCard.tsx b/src/components/academy/MainModelCard.tsx
index cf18a7d..ce2454f 100644
--- a/src/components/academy/MainModelCard.tsx
+++ b/src/components/academy/MainModelCard.tsx
@@ -70,7 +70,7 @@ function MainModelCard({
const user = useUserById(user_id);
const profile_image = user?.profile_image;
- const is_verified = user?.is_verified;
+ const verify_badge = user?.verify_badge;
const user_score = user?.user_score;
const rate = user?.rate;
@@ -436,7 +436,7 @@ function MainModelCard({
{cuorse_name}
-
+
{newScore || 0}
diff --git a/src/components/academy/ModelHead.tsx b/src/components/academy/ModelHead.tsx
index 520635c..c94d640 100644
--- a/src/components/academy/ModelHead.tsx
+++ b/src/components/academy/ModelHead.tsx
@@ -29,6 +29,7 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
first_name,
last_name,
is_verified,
+ verify_badge,
user_score,
rate,
user_level,
@@ -118,12 +119,15 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
+ userId={_id}
+ verifyBadge={verify_badge}
/>
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
diff --git a/src/components/billboards/BillboardDetailContent.tsx b/src/components/billboards/BillboardDetailContent.tsx
index 2d57bb0..987c165 100644
--- a/src/components/billboards/BillboardDetailContent.tsx
+++ b/src/components/billboards/BillboardDetailContent.tsx
@@ -6,7 +6,10 @@ import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDet
import LocalePageShell from "@/components/i18n/LocalePageShell";
import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/MainBillboardCardActions";
import { IAdvertising, IRate } from "@/types/types";
+import { trackExploreInteraction } from "@/api/trackExploreInteraction";
+import Cookies from "js-cookie";
import Image from "next/image";
+import { useEffect } from "react";
import { useTranslation } from "react-i18next";
type BillboardDetailContentProps = {
@@ -20,6 +23,23 @@ export default function BillboardDetailContent({
}: BillboardDetailContentProps) {
const { t } = useTranslation("common");
+ useEffect(() => {
+ if (!billboard?._id) return;
+ const token = Cookies.get("token") || "";
+ if (!token) return;
+ trackExploreInteraction(
+ {
+ targetType: "billboard",
+ targetId: billboard._id,
+ authorId: billboard.creatorId || undefined,
+ contentType: "billboard",
+ action: "view",
+ },
+ token
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [billboard?._id]);
+
return (
diff --git a/src/components/billboards/MainBillboardCard/BillboardCommentsModal.tsx b/src/components/billboards/MainBillboardCard/BillboardCommentsModal.tsx
index cb1977d..e1db7c3 100644
--- a/src/components/billboards/MainBillboardCard/BillboardCommentsModal.tsx
+++ b/src/components/billboards/MainBillboardCard/BillboardCommentsModal.tsx
@@ -28,6 +28,7 @@ export interface User {
last_name: string;
user_name: string;
is_verified: string;
+ verify_badge?: string;
user_type: string;
_id: string;
}
@@ -92,12 +93,14 @@ function BillboardCommentsModal({
size="chat"
rounded="full"
className="ml-2"
+ userId={item?.userId?._id}
+ verifyBadge={item?.userId?.verify_badge}
/>
{item?.userId?.user_name}
-
+
{item.text}
diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx
index cb63ea3..5bd78ff 100644
--- a/src/components/chat/ChatHeader.tsx
+++ b/src/components/chat/ChatHeader.tsx
@@ -11,6 +11,9 @@ import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { isUserOnline, resolveOnlineLabel } from "@/lib/chat/onlineStatus";
+import { glassBackdropStyle } from "@/lib/glassBackdrop";
+
+export const chatHeaderGlassStyle = glassBackdropStyle("chat-header-glass");
export const chatHeaderCircleBtn = cn(
"chat-header-glass gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
@@ -60,6 +63,7 @@ function ChatHeader({
type="button"
onClick={() => router.back()}
className={chatHeaderCircleBtn}
+ style={chatHeaderGlassStyle}
aria-label={t("chats.actions.back")}
>
@@ -69,6 +73,7 @@ function ChatHeader({
href={user?.blocked_you ? "#" : `/users/${user?.user_name}`}
dir="ltr"
className={chatHeaderInfoPill}
+ style={chatHeaderGlassStyle}
onClick={(e) => {
if (user?.blocked_you) e.preventDefault();
}}
@@ -102,6 +107,8 @@ function ChatHeader({
size="xs"
rounded="full"
className="h-[34px] w-[34px] shrink-0"
+ userId={user._id}
+ verifyBadge={user.verify_badge}
/>
) : (
diff --git a/src/components/chat/ChatHeaderMenu.tsx b/src/components/chat/ChatHeaderMenu.tsx
index 99b84ab..29f6747 100644
--- a/src/components/chat/ChatHeaderMenu.tsx
+++ b/src/components/chat/ChatHeaderMenu.tsx
@@ -9,6 +9,7 @@ import { User } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
+import { chatHeaderGlassStyle } from "./ChatHeader";
interface ChatHeaderMenuProps {
user: User | undefined;
@@ -57,6 +58,7 @@ export default function ChatHeaderMenu({
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(buttonClassName, "relative")}
+ style={chatHeaderGlassStyle}
aria-label={t("chats.aria.menu")}
>
diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx
index 5bfd256..ab84777 100644
--- a/src/components/chat/MessageInput.tsx
+++ b/src/components/chat/MessageInput.tsx
@@ -9,7 +9,8 @@ import ViewOnceToggle from "./ViewOnceToggle";
import ChatBoldIcon from "./ChatBoldIcon";
import { formatTimedLabel } from "@/lib/chat/timedMessages";
import { viewOnceLabel } from "@/lib/chat/viewOnce";
-import { chatHeaderCircleBtn } from "./ChatHeader";
+import { chatHeaderCircleBtn, chatHeaderGlassStyle } from "./ChatHeader";
+import { glassBackdropStyle } from "@/lib/glassBackdrop";
import { useTranslation } from "react-i18next";
interface MessageInputProps {
@@ -313,6 +314,7 @@ const MessageInput = ({
disabled={blocked_you || isRecording}
onClick={() => setMenuOpen((o) => !o)}
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
+ style={chatHeaderGlassStyle}
aria-label={t("chats.aria.attach")}
>
@@ -329,6 +331,7 @@ const MessageInput = ({
disabled={blocked_you || isRecording}
onClick={onGiftClick}
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
+ style={chatHeaderGlassStyle}
aria-label={t("chats.aria.gift")}
>
@@ -337,7 +340,10 @@ const MessageInput = ({
{/* Glass input box */}
-
+
@@ -474,6 +481,7 @@ const MessageInput = ({
className={`${chatHeaderCircleBtn} gentle-transition ${
isRecording ? "scale-110 !bg-red-500 text-white" : ""
} active:scale-90`}
+ style={isRecording ? undefined : chatHeaderGlassStyle}
aria-label={t("chats.aria.recordVoice")}
>
{isRecording ?
:
}
diff --git a/src/components/elements/Modal.tsx b/src/components/elements/Modal.tsx
index e86d00b..5e656a8 100644
--- a/src/components/elements/Modal.tsx
+++ b/src/components/elements/Modal.tsx
@@ -2,6 +2,7 @@
import React, { useEffect } from "react";
import { cn } from "@/lib/utils";
+import { glassBackdropStyle } from "@/lib/glassBackdrop";
type ModalProps = {
isOpen: boolean;
@@ -42,15 +43,17 @@ const Modal: React.FC
= ({
"glass-modal-overlay fixed inset-0 flex items-end",
elevated ? "z-[220]" : "z-40"
)}
+ style={glassBackdropStyle("glass-modal-overlay")}
>
e.stopPropagation()}
>
{children}
diff --git a/src/components/explore/ExploreAuthor.tsx b/src/components/explore/ExploreAuthor.tsx
index 51d3d10..7949ebc 100644
--- a/src/components/explore/ExploreAuthor.tsx
+++ b/src/components/explore/ExploreAuthor.tsx
@@ -40,6 +40,8 @@ export default function ExploreAuthor({
alt={userName || displayName}
size="xs"
rounded="full"
+ userId={userId}
+ verifyBadge={data?.user?.verify_badge}
/>
{userName ? `@${userName}` : displayName}
diff --git a/src/components/explore/ExploreGrid.tsx b/src/components/explore/ExploreGrid.tsx
index 97dcf71..d6bc64d 100644
--- a/src/components/explore/ExploreGrid.tsx
+++ b/src/components/explore/ExploreGrid.tsx
@@ -10,7 +10,8 @@ import {
} from "@/api/fetchAcademyExplore";
import { Post } from "@/types/types";
import Cookies from "js-cookie";
-import { useCallback, useEffect, useMemo } from "react";
+import { trackExploreInteraction } from "@/api/trackExploreInteraction";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
import BoldIcon from "@/components/ui/BoldIcon";
@@ -143,6 +144,40 @@ function ExploreCard({
}) {
const router = useRouter();
const media = getPostMedia(post);
+ const articleRef = useRef(null);
+ const trackedRef = useRef(false);
+
+ useEffect(() => {
+ const el = articleRef.current;
+ if (!el || !media) return;
+ trackedRef.current = false;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry?.isIntersecting && !trackedRef.current) {
+ trackedRef.current = true;
+ const token = Cookies.get("token") || "";
+ if (token) {
+ trackExploreInteraction(
+ {
+ targetType: "post",
+ targetId: post._id,
+ authorId: post.user_id,
+ contentType: media.kind === "video" ? "video" : "image",
+ action: "view",
+ },
+ token
+ );
+ }
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "80px 0px", threshold: 0.35 }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [post._id]);
+
if (!media) return null;
const aspect = hashAspect(post._id);
@@ -171,7 +206,7 @@ function ExploreCard({
};
return (
-
+