verify
This commit is contained in:
13
public/images/icons/verify-blue.svg
Normal file
13
public/images/icons/verify-blue.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#002FC6;}
|
||||
</style>
|
||||
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
|
||||
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
|
||||
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
|
||||
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
|
||||
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
|
||||
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
13
public/images/icons/verify-gold.svg
Normal file
13
public/images/icons/verify-gold.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#EDCB00;}
|
||||
</style>
|
||||
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
|
||||
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
|
||||
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
|
||||
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
|
||||
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
|
||||
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
13
public/images/icons/verify-gray.svg
Normal file
13
public/images/icons/verify-gray.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#636360;}
|
||||
</style>
|
||||
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
|
||||
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
|
||||
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
|
||||
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
|
||||
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
|
||||
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
13
public/images/icons/verify-green.svg
Normal file
13
public/images/icons/verify-green.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#06CC00;}
|
||||
</style>
|
||||
<path class="st0" d="M21.6,10.7l-1.4-1.6c-0.3-0.3-0.5-0.9-0.5-1.3V6.2c0-1.1-0.9-1.9-1.9-1.9h-1.7c-0.4,0-1-0.2-1.3-0.5l-1.6-1.4
|
||||
c-0.7-0.6-1.8-0.6-2.5,0L9.2,3.8C8.9,4.1,8.3,4.3,7.9,4.3H6.2c-1.1,0-1.9,0.9-1.9,1.9v1.7c0,0.4-0.2,0.9-0.5,1.2l-1.3,1.6
|
||||
c-0.6,0.7-0.6,1.8,0,2.5l1.3,1.6c0.2,0.3,0.5,0.9,0.5,1.2v1.7c0,1.1,0.9,1.9,1.9,1.9h1.7c0.4,0,1,0.2,1.3,0.5l1.6,1.4
|
||||
c0.7,0.6,1.8,0.6,2.5,0l1.6-1.4c0.3-0.3,0.9-0.5,1.3-0.5h1.7c1.1,0,1.9-0.9,1.9-1.9v-1.7c0-0.4,0.2-1,0.5-1.3l1.4-1.6
|
||||
C22.1,12.6,22.1,11.4,21.6,10.7z M16.2,10.1l-4.8,4.8c-0.1,0.1-0.3,0.2-0.5,0.2s-0.4-0.1-0.5-0.2l-2.4-2.4c-0.3-0.3-0.3-0.8,0-1.1
|
||||
c0.3-0.3,0.8-0.3,1.1,0l1.9,1.9l4.3-4.3c0.3-0.3,0.8-0.3,1.1,0C16.5,9.3,16.5,9.8,16.2,10.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
@@ -1,6 +1,8 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import type { PostLinkKind } from "@/lib/postLinkCaption";
|
||||
|
||||
export type StoryTextOverlay = {
|
||||
type?: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -11,12 +13,25 @@ export type StoryTextOverlay = {
|
||||
align?: "left" | "center" | "right";
|
||||
};
|
||||
|
||||
/** اورلی لینک روی استوری — همون ۶ مورد قابللینک ثبت پست (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) */
|
||||
export type StoryLinkOverlay = {
|
||||
type: "link";
|
||||
link_kind: PostLinkKind;
|
||||
link_id: string;
|
||||
link_title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation?: number;
|
||||
};
|
||||
|
||||
export type StoryOverlay = StoryTextOverlay | StoryLinkOverlay;
|
||||
|
||||
export type StoryItem = {
|
||||
_id: string;
|
||||
media_path: string;
|
||||
media_type: "image" | "video";
|
||||
linked_post_id?: string | null;
|
||||
overlays?: StoryTextOverlay[];
|
||||
overlays?: StoryOverlay[];
|
||||
createdAt?: string;
|
||||
expires_at?: string;
|
||||
viewed?: boolean;
|
||||
@@ -31,6 +46,7 @@ export type StoryFeedUser = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_image?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
stories: StoryItem[];
|
||||
has_unviewed: boolean;
|
||||
@@ -73,7 +89,7 @@ export async function markStoryViewed(
|
||||
export async function createStoryBase64(
|
||||
file: { name: string; type: string; data: string },
|
||||
token: string,
|
||||
overlays: StoryTextOverlay[] = []
|
||||
overlays: StoryOverlay[] = []
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/create-base64`, {
|
||||
method: "POST",
|
||||
@@ -91,7 +107,7 @@ export async function createStoryBase64(
|
||||
|
||||
export async function updateStoryOverlays(
|
||||
storyId: string,
|
||||
overlays: StoryTextOverlay[],
|
||||
overlays: StoryOverlay[],
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}`, {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export type ExploreInteractionPayload = {
|
||||
targetType: "post" | "profile" | "academy";
|
||||
targetType:
|
||||
| "post"
|
||||
| "profile"
|
||||
| "academy"
|
||||
| "shop_product"
|
||||
| "academy_course"
|
||||
| "billboard";
|
||||
targetId: string;
|
||||
authorId?: string;
|
||||
contentType?: "image" | "video" | "academy";
|
||||
contentType?: "image" | "video" | "academy" | "shop_product" | "billboard";
|
||||
action?:
|
||||
| "view"
|
||||
| "profile_visit"
|
||||
|
||||
@@ -27,14 +27,14 @@ function Confirm() {
|
||||
useEffect(() => {
|
||||
const markComplete = async () => {
|
||||
try {
|
||||
const response = await request<{ is_verified?: string }>(
|
||||
const response = await request<{ verify_badge?: string }>(
|
||||
"POST",
|
||||
"/verify/complete",
|
||||
{}
|
||||
);
|
||||
setVerifiedStatus(response?.is_verified ?? "none");
|
||||
setVerifiedStatus(response?.verify_badge ?? "none");
|
||||
} catch {
|
||||
setVerifiedStatus(user?.is_verified ?? "none");
|
||||
setVerifiedStatus(user?.verify_badge ?? "none");
|
||||
}
|
||||
};
|
||||
markComplete();
|
||||
@@ -62,6 +62,8 @@ function Confirm() {
|
||||
alt={user?.user_name || "user profile"}
|
||||
size="xl"
|
||||
rounded="2xl"
|
||||
userId={user?._id}
|
||||
verifyBadge={verifiedStatus ?? user?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
@@ -69,7 +71,7 @@ function Confirm() {
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={verifiedStatus ?? user?.is_verified ?? "none"}
|
||||
verifyBadge={verifiedStatus ?? user?.verify_badge ?? "none"}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
|
||||
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -372,6 +373,24 @@ export default function CourseDetail({
|
||||
}
|
||||
}, [id, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const course = courses[0];
|
||||
if (!course?._id) return;
|
||||
const token = Cookies.get("token") || "";
|
||||
if (!token) return;
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "academy_course",
|
||||
targetId: course._id,
|
||||
authorId: course.user_id || course.academyId,
|
||||
contentType: "academy",
|
||||
action: "view",
|
||||
},
|
||||
token
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [courses[0]?._id]);
|
||||
|
||||
// خرید دوره
|
||||
// const handlePurchase = async () => {
|
||||
// setPurchasing(true);
|
||||
@@ -743,6 +762,8 @@ useEffect(() => {
|
||||
size="chat"
|
||||
rounded="full"
|
||||
fallback="/images/default-avatar.png"
|
||||
userId={typeof item?.user_id === "object" ? item.user_id?._id : undefined}
|
||||
verifyBadge={typeof item?.user_id === "object" ? item.user_id?.verify_badge : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -751,7 +772,7 @@ useEffect(() => {
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
|
||||
{item?.user_id?.user_name || t("common.user")}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
<VerificationBadge verifyBadge={item?.user_id?.verify_badge} />
|
||||
</span>
|
||||
|
||||
{item?.rate > 0 && (
|
||||
|
||||
@@ -33,6 +33,7 @@ interface AcademyOwner {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
is_Register?: string | boolean;
|
||||
user_score?: string | number;
|
||||
}
|
||||
@@ -199,7 +200,7 @@ export default function AcademyProfileClient() {
|
||||
const displayUserName = owner?.user_name || ownerFromApi?.user_name;
|
||||
const displayUserScore =
|
||||
owner?.user_score ?? ownerFromApi?.user_score ?? 0;
|
||||
const isVerified = owner?.is_verified ?? ownerFromApi?.is_verified;
|
||||
const isVerified = owner?.verify_badge ?? ownerFromApi?.verify_badge;
|
||||
const isRegister = owner?.is_Register ?? ownerFromApi?.is_Register;
|
||||
const stats = academy.stats;
|
||||
const shareUrl = `modstagram.com/academy/profile/${academyId}`;
|
||||
@@ -250,6 +251,8 @@ export default function AcademyProfileClient() {
|
||||
: undefined
|
||||
}
|
||||
alt={academyName}
|
||||
userId={owner?._id ?? ownerFromApi?._id}
|
||||
verifyBadge={isVerified}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
@@ -293,10 +296,7 @@ export default function AcademyProfileClient() {
|
||||
)}
|
||||
>
|
||||
{displayUserName}
|
||||
<VerificationBadge
|
||||
isVerified={isVerified}
|
||||
isRegister={isRegister}
|
||||
/>
|
||||
<VerificationBadge verifyBadge={isVerified} />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -31,15 +31,21 @@ type TimeSlot = { start_time: string; end_time: string };
|
||||
|
||||
const DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||
|
||||
function buildUpcomingDates(config: BookingConfig, count = 21) {
|
||||
function buildUpcomingDates(
|
||||
config: BookingConfig,
|
||||
repeatExpiresAt: string | null,
|
||||
count = 21
|
||||
) {
|
||||
const openDays = new Set(config.weeklySchedule.map((entry) => entry.day));
|
||||
const holidaySet = new Set(config.holidayDates.map((d) => d.slice(0, 10)));
|
||||
const expiresAtTime = repeatExpiresAt ? new Date(repeatExpiresAt).getTime() : null;
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date();
|
||||
|
||||
for (let i = 0; i < 60 && dates.length < count; i++) {
|
||||
const date = new Date(cursor);
|
||||
date.setDate(cursor.getDate() + i);
|
||||
if (expiresAtTime !== null && date.getTime() >= expiresAtTime) break;
|
||||
const iso = date.toISOString().slice(0, 10);
|
||||
const dayKey = DAY_KEYS[date.getDay()];
|
||||
if (openDays.has(dayKey) && !holidaySet.has(iso)) {
|
||||
@@ -56,6 +62,7 @@ function BookingFlowPage() {
|
||||
const params = useParams<{ userId: string }>();
|
||||
|
||||
const [config, setConfig] = useState<BookingConfig | null | undefined>(undefined);
|
||||
const [repeatExpiresAt, setRepeatExpiresAt] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<"service" | "datetime" | "confirm">("service");
|
||||
const [selectedService, setSelectedService] = useState<BookingService | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
@@ -63,16 +70,19 @@ function BookingFlowPage() {
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ config: BookingConfig | null }>(
|
||||
request<{ config: BookingConfig | null; repeatExpiresAt?: string | null }>(
|
||||
"GET",
|
||||
`/bookings/public/${params.userId}`
|
||||
).then((res) => setConfig(res?.config ?? null));
|
||||
).then((res) => {
|
||||
setConfig(res?.config ?? null);
|
||||
setRepeatExpiresAt(res?.repeatExpiresAt ?? null);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.userId]);
|
||||
|
||||
const upcomingDates = useMemo(
|
||||
() => (config ? buildUpcomingDates(config) : []),
|
||||
[config]
|
||||
() => (config ? buildUpcomingDates(config, repeatExpiresAt) : []),
|
||||
[config, repeatExpiresAt]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -93,7 +93,8 @@ function GiftPage({ params }: GiftPageProps) {
|
||||
<div className="mt-4 flex justify-center">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.is_verified}
|
||||
is_verified={userDetail?.verify_badge}
|
||||
userId={userDetail?._id}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
|
||||
BIN
src/app/icon.png
BIN
src/app/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 31 KiB |
@@ -4,7 +4,7 @@
|
||||
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
|
||||
import { createStoryBase64, StoryTextOverlay } from "@/api/fetchStories";
|
||||
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
import PostLinkPicker from "@/components/posts/PostLinkPicker";
|
||||
@@ -34,12 +34,13 @@ import {
|
||||
} from "@/lib/postLocationNotice";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import SelectBox from "@/components/elements/SelectBox";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { User } from "@/types/types";
|
||||
import { ICity, IProvince, User } from "@/types/types";
|
||||
|
||||
type CreateMode = "image" | "video" | "story";
|
||||
|
||||
@@ -84,13 +85,40 @@ export default function NewPostPage() {
|
||||
const [selectedShopProducts, setSelectedShopProducts] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [selectedBooking, setSelectedBooking] = useState<SelectedPostLink[]>(
|
||||
[]
|
||||
);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryOverlay[]>([]);
|
||||
const [videoCover, setVideoCover] = useState<File | null>(null);
|
||||
const [existingVideoPath, setExistingVideoPath] = useState<string | null>(null);
|
||||
const [locationNoticeOpen, setLocationNoticeOpen] = useState(false);
|
||||
const [expertiseModalOpen, setExpertiseModalOpen] = useState(false);
|
||||
const [loadingEdit, setLoadingEdit] = useState(false);
|
||||
|
||||
// شهر/استان اختیاری پست — مستقل از موقعیت پروفایل کاربر، مثل بیلبورد/پروژه
|
||||
const [provinces, setProvinces] = useState<IProvince[] | null>(null);
|
||||
const [cities, setCities] = useState<ICity[] | null>(null);
|
||||
const [provinceId, setProvinceId] = useState("");
|
||||
const [cityId, setCityId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
request<{ provinces: IProvince[] }>("GET", "/provinces")
|
||||
.then((res) => setProvinces(res?.provinces || null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provinceId) {
|
||||
setCities(null);
|
||||
return;
|
||||
}
|
||||
request<{ cities: ICity[] }>("GET", `/cities/${provinceId}`)
|
||||
.then((res) => setCities(res?.cities || []))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provinceId]);
|
||||
|
||||
const modeLabels: Record<CreateMode, string> = useMemo(
|
||||
() => ({
|
||||
image: t("posts.modeImage"),
|
||||
@@ -310,7 +338,8 @@ export default function NewPostPage() {
|
||||
selectedBillboards,
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
selectedShopProducts,
|
||||
selectedBooking
|
||||
);
|
||||
|
||||
if (caption.length > POST_CAPTION_MAX_LENGTH) {
|
||||
@@ -442,6 +471,9 @@ export default function NewPostPage() {
|
||||
linked_billboard_id: selectedBillboards[0]?._id ?? null,
|
||||
linked_shop_id: selectedShops[0]?._id ?? null,
|
||||
linked_shop_product_id: selectedShopProducts[0]?._id ?? null,
|
||||
linked_booking_id: selectedBooking[0]?._id ?? null,
|
||||
province_id: provinceId || null,
|
||||
city_id: cityId || null,
|
||||
}, { noToast: true });
|
||||
toast.success(t("posts.postPublished"), { id: "post-upload" });
|
||||
router.push("/");
|
||||
@@ -494,7 +526,8 @@ export default function NewPostPage() {
|
||||
selectedBillboards,
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
selectedShopProducts,
|
||||
selectedBooking
|
||||
);
|
||||
|
||||
const isStory = mode === "story";
|
||||
@@ -674,12 +707,43 @@ export default function NewPostPage() {
|
||||
selectedBillboards={selectedBillboards}
|
||||
selectedShops={selectedShops}
|
||||
selectedShopProducts={selectedShopProducts}
|
||||
selectedBooking={selectedBooking}
|
||||
onPackagesChange={setSelectedPackages}
|
||||
onProjectsChange={setSelectedProjects}
|
||||
onBillboardsChange={setSelectedBillboards}
|
||||
onShopsChange={setSelectedShops}
|
||||
onShopProductsChange={setSelectedShopProducts}
|
||||
onBookingChange={setSelectedBooking}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<SelectBox
|
||||
value={provinceId}
|
||||
onChange={(e) => {
|
||||
setProvinceId(e.target.value);
|
||||
setCityId("");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("posts.provinceOptional")}</option>
|
||||
{provinces?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
<SelectBox
|
||||
value={cityId}
|
||||
onChange={(e) => setCityId(e.target.value)}
|
||||
disabled={!provinceId}
|
||||
>
|
||||
<option value="">{t("posts.cityOptional")}</option>
|
||||
{cities?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -202,7 +202,8 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.is_verified}
|
||||
is_verified={userDetail?.verify_badge}
|
||||
userId={userDetail?._id}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
|
||||
@@ -76,7 +76,7 @@ function BookingNamePage() {
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
className="flex w-full flex-1 flex-col items-center pb-28"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">
|
||||
@@ -101,7 +101,7 @@ function BookingNamePage() {
|
||||
</small>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthFormFooter className="fixed inset-x-0 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 mx-auto mt-0 w-full max-w-xl bg-background/95 px-4 py-3 backdrop-blur-sm">
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
|
||||
@@ -22,6 +22,7 @@ type PreviewConfig = {
|
||||
weeklySchedule: { day: string; ranges: { start_time: string; end_time: string }[] }[];
|
||||
holidayDates: string[];
|
||||
depositRequired: boolean;
|
||||
weeklyRepeatCount?: number | null;
|
||||
};
|
||||
|
||||
function BookingPreviewPage() {
|
||||
@@ -57,7 +58,7 @@ function BookingPreviewPage() {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<div className="flex w-full flex-1 flex-col items-center pb-28">
|
||||
<AuthPageContent>
|
||||
<span className="mb-4 text-xl font-bold">
|
||||
{t("booking.previewTitle")}
|
||||
@@ -109,6 +110,11 @@ function BookingPreviewPage() {
|
||||
{t("booking.holidaysTitle")}: {config.holidayDates.join("، ")}
|
||||
</p>
|
||||
)}
|
||||
{config.weeklyRepeatCount ? (
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
{t("booking.weeklyRepeatCountTitle")}: {config.weeklyRepeatCount}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
{t("booking.depositTitle")}:{" "}
|
||||
{config.depositRequired ? t("common.yes") : t("common.no")}
|
||||
@@ -117,7 +123,7 @@ function BookingPreviewPage() {
|
||||
</div>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthFormFooter className="sticky bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 mt-0 bg-background/95 py-3 backdrop-blur-sm">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
|
||||
@@ -39,7 +39,8 @@ function BookingSchedulePage() {
|
||||
);
|
||||
const [holidayDates, setHolidayDates] = useState<string[]>([]);
|
||||
const [newHoliday, setNewHoliday] = useState("");
|
||||
const [depositRequired, setDepositRequired] = useState(false);
|
||||
const [depositRequired, setDepositRequired] = useState(true);
|
||||
const [weeklyRepeatCount, setWeeklyRepeatCount] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!configId) return;
|
||||
@@ -48,6 +49,7 @@ function BookingSchedulePage() {
|
||||
weeklySchedule?: WeeklyScheduleEntry[];
|
||||
holidayDates?: string[];
|
||||
depositRequired?: boolean;
|
||||
weeklyRepeatCount?: number | null;
|
||||
};
|
||||
}>("GET", `/bookings/${configId}`).then((res) => {
|
||||
const config = res?.config;
|
||||
@@ -67,7 +69,10 @@ function BookingSchedulePage() {
|
||||
config.holidayDates.map((d) => new Date(d).toISOString().slice(0, 10))
|
||||
);
|
||||
}
|
||||
if (config.depositRequired) setDepositRequired(true);
|
||||
setDepositRequired(Boolean(config.depositRequired));
|
||||
setWeeklyRepeatCount(
|
||||
config.weeklyRepeatCount ? String(config.weeklyRepeatCount) : ""
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [configId]);
|
||||
@@ -130,11 +135,18 @@ function BookingSchedulePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedRepeatCount = weeklyRepeatCount.trim();
|
||||
if (trimmedRepeatCount && (!/^\d+$/.test(trimmedRepeatCount) || Number(trimmedRepeatCount) < 1)) {
|
||||
toast.error(t("booking.weeklyRepeatCountInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request("PATCH", `/bookings/${configId}/schedule`, {
|
||||
weeklySchedule,
|
||||
holidayDates,
|
||||
depositRequired,
|
||||
weeklyRepeatCount: trimmedRepeatCount ? Number(trimmedRepeatCount) : null,
|
||||
});
|
||||
router.push("/settings/booking/new/preview");
|
||||
} catch (err: unknown) {
|
||||
@@ -148,7 +160,7 @@ function BookingSchedulePage() {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center overflow-y-auto pb-6">
|
||||
<div className="flex w-full flex-1 flex-col items-center overflow-y-auto pb-28">
|
||||
<AuthPageContent>
|
||||
<span className="mb-4 text-xl font-bold">
|
||||
{t("booking.scheduleTitle")}
|
||||
@@ -206,6 +218,22 @@ function BookingSchedulePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full max-w-sm rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
|
||||
<span className="font-semibold">{t("booking.weeklyRepeatCountTitle")}</span>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{t("booking.weeklyRepeatCountHint")}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={weeklyRepeatCount}
|
||||
onChange={(e) => setWeeklyRepeatCount(e.target.value)}
|
||||
placeholder={t("booking.weeklyRepeatCountPlaceholder")}
|
||||
className="mt-2 w-24 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full max-w-sm">
|
||||
<span className="mb-2 block font-semibold">
|
||||
{t("booking.holidaysTitle")}
|
||||
@@ -260,7 +288,7 @@ function BookingSchedulePage() {
|
||||
</p>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthFormFooter className="fixed inset-x-0 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 mx-auto mt-0 w-full max-w-xl bg-background/95 px-4 py-3 backdrop-blur-sm">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
|
||||
@@ -7,9 +7,11 @@ import AuthPageLayout, {
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useBookingWizardId } from "@/hooks/useBookingWizardId";
|
||||
import { Service, User } from "@/types/types";
|
||||
import { dataURLtoBlob } from "@/helpers/helpers";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -24,11 +26,16 @@ function BookingServicesPage() {
|
||||
|
||||
const [services, setServices] = useState<Service[] | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [isAddModalOpen, setAddModalOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServices = () =>
|
||||
request<{ user: User }>("GET", "/profile?services=1").then((res) => {
|
||||
setServices(res?.user?.services || []);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -53,6 +60,54 @@ function BookingServicesPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddService = async (newService: Service) => {
|
||||
if (!services) return;
|
||||
setAdding(true);
|
||||
const previousIds = new Set(
|
||||
services.map((s) => s.id || s._id || "").filter(Boolean)
|
||||
);
|
||||
const nextServices = [...services, newService];
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("services", JSON.stringify(nextServices));
|
||||
nextServices.forEach((service) => {
|
||||
if (!service.image) return;
|
||||
if (service.image.startsWith("/services/")) {
|
||||
formData.append(`existingServiceImages[${service.id}]`, service.image);
|
||||
} else {
|
||||
const blob = dataURLtoBlob(service.image);
|
||||
formData.append(`serviceImages[${service.id}]`, blob, `service-${service.id}.jpg`);
|
||||
}
|
||||
});
|
||||
|
||||
await request("POST", "/verify/services", formData, {
|
||||
headers: { Accept: "application/json", "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
const res = await request<{ user: User }>("GET", "/profile?services=1");
|
||||
const refreshed = res?.user?.services || [];
|
||||
setServices(refreshed);
|
||||
setAddModalOpen(false);
|
||||
|
||||
const addedService = refreshed.find(
|
||||
(s) => !previousIds.has(s.id || s._id || "")
|
||||
);
|
||||
if (addedService) {
|
||||
const addedId = addedService.id || addedService._id || "";
|
||||
if (addedId) toggle(addedId);
|
||||
}
|
||||
toast.success(t("settings.edit.save"));
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("shops.unknownError");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!configId || selectedIds.size === 0) return;
|
||||
try {
|
||||
@@ -71,11 +126,20 @@ function BookingServicesPage() {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<div className="flex w-full flex-1 flex-col items-center pb-28">
|
||||
<AuthPageContent>
|
||||
<span className="mb-4 text-xl font-bold">
|
||||
{t("booking.servicesTitle")}
|
||||
</span>
|
||||
<div className="mb-4 flex w-full max-w-sm items-center justify-between">
|
||||
<span className="text-xl font-bold">
|
||||
{t("booking.servicesTitle")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="text-sm font-semibold text-[#0095f6]"
|
||||
>
|
||||
+ {t("settings.edit.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{services === null ? (
|
||||
<p className="py-8 text-sm text-neutral-500">
|
||||
@@ -97,19 +161,23 @@ function BookingServicesPage() {
|
||||
const id = service.id || service._id || "";
|
||||
const selected = selectedIds.has(id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(id)}
|
||||
className={`flex w-full items-center gap-2 rounded-2xl px-2 ${
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggle(id);
|
||||
}}
|
||||
className={`relative w-full cursor-pointer rounded-2xl ${
|
||||
selected ? "bg-[#0095f6]/10" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
className={`absolute -right-1 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||||
selected
|
||||
? "border-[#0095f6] bg-[#0095f6]"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
: "border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
@@ -117,13 +185,13 @@ function BookingServicesPage() {
|
||||
)}
|
||||
</span>
|
||||
<ServiceItem service={service} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthFormFooter className="fixed inset-x-0 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 mx-auto mt-0 w-full max-w-xl bg-background/95 px-4 py-3 backdrop-blur-sm">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
@@ -134,6 +202,13 @@ function BookingServicesPage() {
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
{isAddModalOpen && (
|
||||
<AddServiceModal
|
||||
isModalOpen={isAddModalOpen}
|
||||
onClose={() => !adding && setAddModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
/>
|
||||
)}
|
||||
</AuthPageLayout>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ type BookingBuyer = {
|
||||
profile_image?: string;
|
||||
user_level?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
|
||||
type ReceivedBooking = {
|
||||
@@ -87,7 +88,8 @@ function BookingReservationsPage() {
|
||||
first_name={booking.buyer?.first_name}
|
||||
last_name={booking.buyer?.last_name}
|
||||
user_name={booking.buyer?.user_name}
|
||||
is_verified={booking.buyer?.is_verified}
|
||||
is_verified={booking.buyer?.verify_badge}
|
||||
userId={booking.buyer?._id}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]"
|
||||
|
||||
@@ -21,6 +21,7 @@ type NearbyUser = {
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
is_Register?: string | boolean;
|
||||
distanceMeters: number;
|
||||
};
|
||||
@@ -377,10 +378,7 @@ export default function NearbyFriendsPage() {
|
||||
<span className="truncate font-semibold" dir="ltr">
|
||||
{item.user_name}
|
||||
</span>
|
||||
<VerificationBadge
|
||||
isVerified={item.is_verified}
|
||||
isRegister={item.is_Register}
|
||||
/>
|
||||
<VerificationBadge verifyBadge={item.verify_badge} />
|
||||
</div>
|
||||
{(item.first_name || item.last_name) && (
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
|
||||
@@ -6,7 +6,7 @@ import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import StoryRing from "@/components/stories/StoryRing";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import Link from "next/link";
|
||||
@@ -30,6 +30,7 @@ export interface IMessage {
|
||||
last_name: string;
|
||||
user_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
profile_image?: string;
|
||||
last_online?: string;
|
||||
_id: string;
|
||||
@@ -236,6 +237,7 @@ function Chats() {
|
||||
<StoryRing
|
||||
hasStory={Boolean(storyMeta?.hasStory)}
|
||||
hasUnviewed={Boolean(storyMeta?.hasUnviewed)}
|
||||
tierColor={verifyBadgeToTierColor(item.verify_badge)}
|
||||
avatarSize={48}
|
||||
>
|
||||
<ProfileAvatar
|
||||
@@ -268,7 +270,7 @@ function Chats() {
|
||||
<span dir="ltr" className="truncate">
|
||||
{item.user_name}
|
||||
</span>
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
<VerificationBadge verifyBadge={item.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ type RoomMember = {
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
|
||||
type RoomMessage = {
|
||||
@@ -186,7 +187,7 @@ function RoomMessageRow({
|
||||
className="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span>{senderName}</span>
|
||||
<VerificationBadge isVerified={msg.sender?.is_verified} />
|
||||
<VerificationBadge verifyBadge={msg.sender?.verify_badge} />
|
||||
</Link>
|
||||
) : null}
|
||||
{msg.replyTo ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import Container from "@/components/elements/Container";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill } from "@/components/chat/ChatHeader";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill, chatHeaderGlassStyle } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -153,12 +153,13 @@ export default function ShopChatPage() {
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill}>
|
||||
<div dir="ltr" className={chatHeaderInfoPill} style={chatHeaderGlassStyle}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate text-[15px] font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
|
||||
{counterpart?.name}
|
||||
@@ -273,6 +274,7 @@ export default function ShopChatPage() {
|
||||
disabled={sending}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition disabled:opacity-40")}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.attach")}
|
||||
>
|
||||
<ChatBoldIcon name="attachment" size={22} className="text-current" />
|
||||
@@ -299,6 +301,7 @@ export default function ShopChatPage() {
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition active:scale-90 disabled:opacity-40")}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.send")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
|
||||
@@ -191,6 +191,8 @@ function LicensePage() {
|
||||
rounded="2xl"
|
||||
className="h-[130px] w-[130px]"
|
||||
fallback="/images/placeholder.png"
|
||||
userId={user?._id}
|
||||
verifyBadge={user?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LanguageSettingRow from "@/components/settings/LanguageSettingRow";
|
||||
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
@@ -157,6 +158,8 @@ export default function UserSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AccountAnalyticsWidget />
|
||||
|
||||
<LanguageSettingRow />
|
||||
|
||||
<Link
|
||||
@@ -167,6 +170,14 @@ export default function UserSettingsPage() {
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings/booking"
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<span className="font-semibold">{t("settings.nav.booking")}</span>
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div>
|
||||
<p className="font-semibold">{t("settings.allowSavePosts")}</p>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (
|
||||
<nav
|
||||
className={cn(
|
||||
"fixed bottom-2 left-1/2 z-[55] -translate-x-1/2 pb-2",
|
||||
PAGE_SHELL_CLASS
|
||||
)}
|
||||
>
|
||||
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-[#C3C3C3]/60 p-2.5 shadow-lg dark:border-white/10">
|
||||
{TAB_KEYS.map((tab) => {
|
||||
const isActive =
|
||||
currentPage === tab.href ||
|
||||
(tab.href === "/settings" && currentPage.startsWith("/settings")) ||
|
||||
(tab.href === "/projects" &&
|
||||
(currentPage.startsWith("/projects") ||
|
||||
currentPage === "/new-project")) ||
|
||||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className="gentle-transition flex flex-col items-center gap-0.5 px-2 py-1 active:scale-95"
|
||||
>
|
||||
<BoldIcon
|
||||
name={tab.icon}
|
||||
size={28}
|
||||
tinted
|
||||
className={cn(
|
||||
isActive
|
||||
? "text-[#ff107d]"
|
||||
: isDark
|
||||
? "text-white"
|
||||
: "text-zinc-600"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold gentle-transition",
|
||||
isActive ? "text-pink-500" : isDark ? "text-neutral-100" : "text-zinc-600"
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
"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 (
|
||||
<nav
|
||||
className={cn(
|
||||
"fixed bottom-2 left-1/2 z-[55] -translate-x-1/2 pb-2",
|
||||
PAGE_SHELL_CLASS
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="glass-panel gentle-transition flex justify-evenly rounded-full border border-[#C3C3C3]/60 p-2.5 shadow-lg dark:border-white/10"
|
||||
style={glassBackdropStyle("glass-panel")}
|
||||
>
|
||||
{TAB_KEYS.map((tab) => {
|
||||
const isActive =
|
||||
currentPage === tab.href ||
|
||||
(tab.href === "/settings" && currentPage.startsWith("/settings")) ||
|
||||
(tab.href === "/projects" &&
|
||||
(currentPage.startsWith("/projects") ||
|
||||
currentPage === "/new-project")) ||
|
||||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className="gentle-transition flex flex-col items-center gap-0.5 px-2 py-1 active:scale-95"
|
||||
>
|
||||
<BoldIcon
|
||||
name={tab.icon}
|
||||
size={28}
|
||||
tinted
|
||||
className={cn(
|
||||
isActive
|
||||
? "text-[#ff107d]"
|
||||
: isDark
|
||||
? "text-white"
|
||||
: "text-zinc-600"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold gentle-transition",
|
||||
isActive ? "text-pink-500" : isDark ? "text-neutral-100" : "text-zinc-600"
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
{cuorse_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</h2>
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{newScore || 0}</span>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
<ModelHeadRowTwo
|
||||
first_name={first_name}
|
||||
last_name={last_name}
|
||||
is_verified={is_verified}
|
||||
verify_badge={verify_badge}
|
||||
is_Register={is_Register}
|
||||
user_name={user_name}
|
||||
user_score={user_score}
|
||||
|
||||
@@ -30,6 +30,8 @@ function AuthUserDetails() {
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="mb-3"
|
||||
userId={userDetail?._id}
|
||||
verifyBadge={userDetail?.verify_badge}
|
||||
/>
|
||||
<span className="text-lg font-bold">
|
||||
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
|
||||
|
||||
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-1 mt-1 font-semibold">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{item?.userId?.user_name}
|
||||
<VerificationBadge isVerified={item?.userId?.is_verified} />
|
||||
<VerificationBadge verifyBadge={item?.userId?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
<p>{item.text}</p>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<FiMoreVertical size={20} strokeWidth={2.5} />
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<ChatBoldIcon name="attachment" size={22} className="text-current" />
|
||||
@@ -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")}
|
||||
>
|
||||
<BoldIcon name="gift" size={20} tinted className="text-current" />
|
||||
@@ -337,7 +340,10 @@ const MessageInput = ({
|
||||
</div>
|
||||
|
||||
{/* Glass input box */}
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<div
|
||||
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
style={glassBackdropStyle("glass-chat-input")}
|
||||
>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
@@ -443,6 +449,7 @@ const MessageInput = ({
|
||||
disabled={blocked_you}
|
||||
onClick={sendMessage}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition active:scale-90`}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.send")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
@@ -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 ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
|
||||
@@ -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<ModalProps> = ({
|
||||
"glass-modal-overlay fixed inset-0 flex items-end",
|
||||
elevated ? "z-[220]" : "z-40"
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-overlay")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"glass-modal-panel glass-modal-panel--sheet relative container mx-auto w-full p-5",
|
||||
panelClassName
|
||||
)}
|
||||
style={
|
||||
height === "auto" || height === "fit" ? undefined : { height }
|
||||
}
|
||||
style={{
|
||||
...glassBackdropStyle("glass-modal-panel"),
|
||||
...(height === "auto" || height === "fit" ? undefined : { height }),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function ExploreAuthor({
|
||||
alt={userName || displayName}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
userId={userId}
|
||||
verifyBadge={data?.user?.verify_badge}
|
||||
/>
|
||||
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{userName ? `@${userName}` : displayName}
|
||||
|
||||
@@ -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<HTMLElement | null>(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 (
|
||||
<article className="mb-2 break-inside-avoid">
|
||||
<article ref={articleRef} className="mb-2 break-inside-avoid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPost}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useScrollDirection } from "@/hooks/useScrollDirection";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop";
|
||||
|
||||
function Header() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -129,7 +130,7 @@ function Header() {
|
||||
: "translate-y-0 opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className={headerBarClass}>
|
||||
<div className={headerBarClass} style={glassBackdropStyle("glass-panel")}>
|
||||
{compact ? (
|
||||
<>
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildStorageUrl } from "./BaseUrl";
|
||||
import StoryRing, { verifyBadgeToTierColor, STORY_RING_PX } from "@/components/stories/StoryRing";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
|
||||
const sizeMap = {
|
||||
xxs: "h-5 w-5",
|
||||
@@ -12,6 +16,16 @@ const sizeMap = {
|
||||
xl: "h-[280px] w-[280px]",
|
||||
};
|
||||
|
||||
const sizePxMap: Record<keyof typeof sizeMap, number> = {
|
||||
xxs: 20,
|
||||
xs: 32,
|
||||
sm: 40,
|
||||
chat: 56,
|
||||
md: 72,
|
||||
lg: 120,
|
||||
xl: 280,
|
||||
};
|
||||
|
||||
type ProfileAvatarProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
@@ -19,6 +33,9 @@ type ProfileAvatarProps = {
|
||||
rounded?: "2xl" | "xl" | "full";
|
||||
className?: string;
|
||||
fallback?: string;
|
||||
/** When provided, wraps the avatar in a story ring reflecting this user's story/verify-tier status. */
|
||||
userId?: string | null;
|
||||
verifyBadge?: string | null;
|
||||
};
|
||||
|
||||
function ProfileAvatar({
|
||||
@@ -28,6 +45,8 @@ function ProfileAvatar({
|
||||
rounded = "2xl",
|
||||
className,
|
||||
fallback = "/images/fake-avatar.png",
|
||||
userId,
|
||||
verifyBadge,
|
||||
}: ProfileAvatarProps) {
|
||||
const roundedClass =
|
||||
rounded === "full"
|
||||
@@ -38,7 +57,13 @@ function ProfileAvatar({
|
||||
|
||||
const imageSrc = src ? buildStorageUrl(src) : fallback;
|
||||
|
||||
return (
|
||||
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
|
||||
|
||||
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
|
||||
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
|
||||
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
|
||||
// visible spacing (see the search-list "avatar stuck to text" regression).
|
||||
const image = (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
@@ -49,10 +74,39 @@ function ProfileAvatar({
|
||||
"profile-avatar aspect-square object-cover shrink-0",
|
||||
sizeMap[size],
|
||||
roundedClass,
|
||||
className
|
||||
!userId && className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
return image;
|
||||
}
|
||||
|
||||
// Keep the total rendered footprint equal to sizePxMap[size] in both states.
|
||||
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
|
||||
// pre-shrink the photo by the ring's width; when it's false we pass the full size
|
||||
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
|
||||
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
|
||||
// avatar with a ring never shifts that row's height or the next element's position.
|
||||
const avatarSizeForRing = hasStory
|
||||
? Math.max(0, sizePxMap[size] - STORY_RING_PX * 2)
|
||||
: sizePxMap[size];
|
||||
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
|
||||
|
||||
return (
|
||||
<StoryRing
|
||||
hasStory={hasStory}
|
||||
hasUnviewed={hasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(verifyBadge)}
|
||||
rounded={rounded === "full" ? "full" : "xl"}
|
||||
avatarSize={avatarSizeForRing}
|
||||
avatarRadius={cornerRadius}
|
||||
className={className}
|
||||
>
|
||||
{image}
|
||||
</StoryRing>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileAvatar;
|
||||
|
||||
@@ -5,6 +5,7 @@ import ProfileAvatar from "./ProfileAvatar";
|
||||
import VerificationBadge from "./VerificationBadge";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import UserInfoClient from "./UserInfoClient";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IUserInfoProps {
|
||||
profile_image: string | null | undefined;
|
||||
@@ -13,7 +14,10 @@ interface IUserInfoProps {
|
||||
last_name: string | undefined;
|
||||
user_name: string | undefined;
|
||||
is_verified: string | null | undefined;
|
||||
userId?: string | null;
|
||||
noLink?: boolean;
|
||||
/** Rendered on the far end of the username row (e.g. score/rate stats) — stretches the row to full width when set. */
|
||||
trailingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
function UserInfo({
|
||||
@@ -23,7 +27,9 @@ function UserInfo({
|
||||
last_name,
|
||||
user_name,
|
||||
is_verified,
|
||||
userId,
|
||||
noLink,
|
||||
trailingContent,
|
||||
}: IUserInfoProps) {
|
||||
const avatar = (
|
||||
<ProfileAvatar
|
||||
@@ -32,20 +38,28 @@ function UserInfo({
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
className="ml-3"
|
||||
userId={userId}
|
||||
verifyBadge={is_verified}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-xs max-sm:text-[10px] md:text-sm font-semibold",
|
||||
trailingContent && "w-full"
|
||||
)}
|
||||
>
|
||||
{noLink ? avatar : <Link href={`/users/${user_name}`}>{avatar}</Link>}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={cn("flex flex-col gap-1", trailingContent && "min-w-0 flex-1")}>
|
||||
<UserInfoClient user_level={user_level} />
|
||||
<h1>{formatFullName(first_name, last_name)}</h1>
|
||||
<h2 className="flex items-center gap-1 mt-1">
|
||||
<h2 className="flex items-center justify-between gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<VerificationBadge verifyBadge={is_verified} />
|
||||
</span>
|
||||
{trailingContent}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,107 +7,88 @@ import { useTranslation } from "react-i18next";
|
||||
export const VERIFICATION_BADGE_SIZE = 14;
|
||||
|
||||
export const VERIFICATION_COLORS = {
|
||||
verified: "#2196EB",
|
||||
license: "#EAB308",
|
||||
pending: "#9CA3AF",
|
||||
gray: "#636360",
|
||||
blue: "#002FC6",
|
||||
green: "#06CC00",
|
||||
gold: "#EDCB00",
|
||||
} as const;
|
||||
|
||||
export type VerifyBadgeTier = "none" | "gray" | "blue" | "green" | "gold";
|
||||
|
||||
type VerificationBadgeProps = {
|
||||
isVerified?: string | null;
|
||||
isRegister?: string | boolean | null;
|
||||
verifyBadge?: VerifyBadgeTier | string | null;
|
||||
className?: string;
|
||||
size?: number;
|
||||
/** Override default status-based icon color (e.g. golden on academy profile). */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export function resolveVerificationStatus(
|
||||
isVerified?: string | null,
|
||||
_isRegister?: string | boolean | null
|
||||
): string | null {
|
||||
// تیک خاکستری فقط با is_verified=pending (یعنی کارت ملی ارسال شده)
|
||||
// تکمیل ثبتنام بدون کارت ملی نباید تیک نشان دهد
|
||||
if (!isVerified || isVerified === "none" || isVerified === "rejected") {
|
||||
verifyBadge?: string | null
|
||||
): VerifyBadgeTier | null {
|
||||
if (
|
||||
!verifyBadge ||
|
||||
verifyBadge === "none" ||
|
||||
!(verifyBadge in VERIFICATION_COLORS)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isVerified;
|
||||
return verifyBadge as VerifyBadgeTier;
|
||||
}
|
||||
|
||||
const VERIFICATION_ICON_SRC: Record<Exclude<VerifyBadgeTier, "none">, string> = {
|
||||
gray: "/images/icons/verify-gray.svg",
|
||||
blue: "/images/icons/verify-blue.svg",
|
||||
green: "/images/icons/verify-green.svg",
|
||||
gold: "/images/icons/verify-gold.svg",
|
||||
};
|
||||
|
||||
export function getVerificationIcon(
|
||||
isVerified?: string | null,
|
||||
isRegister?: string | boolean | null,
|
||||
verifyBadge?: string | null,
|
||||
labels?: {
|
||||
licenseAlt: string;
|
||||
verifiedAlt: string;
|
||||
pendingAlt: string;
|
||||
grayAlt: string;
|
||||
blueAlt: string;
|
||||
greenAlt: string;
|
||||
goldAlt: string;
|
||||
}
|
||||
): { src: string; alt: string; color: string } | null {
|
||||
const status = resolveVerificationStatus(isVerified, isRegister);
|
||||
if (!status) return null;
|
||||
): { src: string; alt: string } | null {
|
||||
const status = resolveVerificationStatus(verifyBadge);
|
||||
if (!status || status === "none") return null;
|
||||
|
||||
if (status === "true") {
|
||||
return {
|
||||
src: "/images/icons/verify2.svg",
|
||||
alt: labels?.licenseAlt ?? "License badge",
|
||||
color: VERIFICATION_COLORS.license,
|
||||
};
|
||||
}
|
||||
if (status === "verified") {
|
||||
return {
|
||||
src: "/images/icons/verify.svg",
|
||||
alt: labels?.verifiedAlt ?? "Verified badge",
|
||||
color: VERIFICATION_COLORS.verified,
|
||||
};
|
||||
}
|
||||
if (status === "pending") {
|
||||
return {
|
||||
src: "/images/icons/not-verify.svg",
|
||||
alt: labels?.pendingAlt ?? "Pending verification",
|
||||
color: VERIFICATION_COLORS.pending,
|
||||
};
|
||||
}
|
||||
const altByTier: Record<Exclude<VerifyBadgeTier, "none">, string> = {
|
||||
gray: labels?.grayAlt ?? "Pending verification",
|
||||
blue: labels?.blueAlt ?? "Verified badge",
|
||||
green: labels?.greenAlt ?? "License badge",
|
||||
gold: labels?.goldAlt ?? "Gold badge",
|
||||
};
|
||||
|
||||
return null;
|
||||
return { src: VERIFICATION_ICON_SRC[status], alt: altByTier[status] };
|
||||
}
|
||||
|
||||
function VerificationBadge({
|
||||
isVerified,
|
||||
isRegister,
|
||||
verifyBadge,
|
||||
className,
|
||||
size,
|
||||
color,
|
||||
}: VerificationBadgeProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const icon = getVerificationIcon(isVerified, isRegister, {
|
||||
licenseAlt: t("verificationBadge.licenseAlt"),
|
||||
verifiedAlt: t("verificationBadge.verifiedAlt"),
|
||||
pendingAlt: t("verificationBadge.pendingAlt"),
|
||||
const icon = getVerificationIcon(verifyBadge, {
|
||||
grayAlt: t("verificationBadge.pendingAlt"),
|
||||
blueAlt: t("verificationBadge.verifiedAlt"),
|
||||
greenAlt: t("verificationBadge.licenseAlt"),
|
||||
goldAlt: t("verificationBadge.goldAlt", "Gold badge"),
|
||||
});
|
||||
if (!icon) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={icon.alt}
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={icon.alt}
|
||||
title={icon.alt}
|
||||
className={cn(
|
||||
"inline-block shrink-0 bg-current align-middle",
|
||||
"inline-block shrink-0 object-contain align-middle",
|
||||
size == null && "h-[1em] w-[1em]",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...(size != null ? { width: size, height: size } : undefined),
|
||||
color: color ?? icon.color,
|
||||
WebkitMaskImage: `url(${icon.src})`,
|
||||
maskImage: `url(${icon.src})`,
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
maskPosition: "center",
|
||||
WebkitMaskSize: "contain",
|
||||
maskSize: "contain",
|
||||
}}
|
||||
style={size != null ? { width: size, height: size } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import "slick-carousel/slick/slick-theme.css";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import SwipeImageSlider from "./SwipeImageSlider";
|
||||
import ExpandableCaption from "@/components/posts/ExpandableCaption";
|
||||
import StoryRing from "@/components/stories/StoryRing";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
import {
|
||||
@@ -66,7 +66,7 @@ function MainModelCard({
|
||||
const user = useUserById(user_id);
|
||||
const storyMeta = useStoryMetaForUser(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;
|
||||
|
||||
@@ -202,6 +202,7 @@ function MainModelCard({
|
||||
<StoryRing
|
||||
hasStory={storyMeta.hasStory}
|
||||
hasUnviewed={storyMeta.hasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(verify_badge)}
|
||||
rounded="xl"
|
||||
animated={false}
|
||||
avatarSize={storyMeta.hasStory ? 67 : 72}
|
||||
@@ -235,7 +236,7 @@ function MainModelCard({
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
{user_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</h2>
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{user_score || 0}</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
const { t } = useTranslation("common");
|
||||
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;
|
||||
// console.log(user);
|
||||
@@ -192,7 +192,7 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
{user_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{user_score || 0}</span>
|
||||
|
||||
@@ -28,6 +28,7 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
first_name,
|
||||
last_name,
|
||||
is_verified,
|
||||
verify_badge,
|
||||
user_score,
|
||||
rate,
|
||||
user_level,
|
||||
@@ -115,12 +116,15 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
userId={_id}
|
||||
verifyBadge={verify_badge}
|
||||
/>
|
||||
</div>
|
||||
<ModelHeadRowTwo
|
||||
first_name={first_name}
|
||||
last_name={last_name}
|
||||
is_verified={is_verified}
|
||||
verify_badge={verify_badge}
|
||||
is_Register={is_Register}
|
||||
user_name={user_name}
|
||||
user_score={user_score}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ProfileHeadRowTwoProps {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string | null;
|
||||
is_Register?: string | boolean | null;
|
||||
user_name: string;
|
||||
user_score?: string;
|
||||
@@ -23,12 +24,12 @@ function ModelHeadRowTwo({
|
||||
user_score,
|
||||
rate,
|
||||
user_id,
|
||||
is_verified: isVerifiedProp,
|
||||
verify_badge: verifyBadgeProp,
|
||||
is_Register: isRegisterProp,
|
||||
isFa = true,
|
||||
}: ProfileHeadRowTwoProps) {
|
||||
const user = useUserById(user_id);
|
||||
const is_verified = isVerifiedProp ?? user?.is_verified;
|
||||
const verify_badge = verifyBadgeProp ?? user?.verify_badge;
|
||||
const is_Register = isRegisterProp ?? user?.is_Register;
|
||||
return (
|
||||
<div className="py-2 grid w-full grid-cols-3 items-end text-xs md:text-sm font-semibold">
|
||||
@@ -76,10 +77,7 @@ function ModelHeadRowTwo({
|
||||
)}
|
||||
>
|
||||
{user_name}
|
||||
<VerificationBadge
|
||||
isVerified={is_verified}
|
||||
isRegister={is_Register}
|
||||
/>
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface CommentUser {
|
||||
profile_image: string | null;
|
||||
user_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
@@ -129,6 +130,8 @@ function CommentRow({
|
||||
size="chat"
|
||||
rounded="full"
|
||||
className="shrink-0"
|
||||
userId={item.user?._id}
|
||||
verifyBadge={item.user?.verify_badge}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -23,11 +23,13 @@ interface PostLinkPickerProps {
|
||||
selectedBillboards: SelectedPostLink[];
|
||||
selectedShops: SelectedPostLink[];
|
||||
selectedShopProducts: SelectedPostLink[];
|
||||
selectedBooking: SelectedPostLink[];
|
||||
onPackagesChange: (links: SelectedPostLink[]) => void;
|
||||
onProjectsChange: (links: SelectedPostLink[]) => void;
|
||||
onBillboardsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopProductsChange: (links: SelectedPostLink[]) => void;
|
||||
onBookingChange: (links: SelectedPostLink[]) => void;
|
||||
}
|
||||
|
||||
function LinkListItem({
|
||||
@@ -336,11 +338,13 @@ export default function PostLinkPicker({
|
||||
selectedBillboards,
|
||||
selectedShops,
|
||||
selectedShopProducts,
|
||||
selectedBooking,
|
||||
onPackagesChange,
|
||||
onProjectsChange,
|
||||
onBillboardsChange,
|
||||
onShopsChange,
|
||||
onShopProductsChange,
|
||||
onBookingChange,
|
||||
}: PostLinkPickerProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
@@ -350,10 +354,12 @@ export default function PostLinkPicker({
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [billboards, setBillboards] = useState<IAdvertising[]>([]);
|
||||
const [shops, setShops] = useState<MyShop[]>([]);
|
||||
const [bookingConfig, setBookingConfig] = useState<{ name: string } | null>(null);
|
||||
const [loadingCourses, setLoadingCourses] = useState(true);
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
const [loadingBillboards, setLoadingBillboards] = useState(true);
|
||||
const [loadingShops, setLoadingShops] = useState(true);
|
||||
const [loadingBooking, setLoadingBooking] = useState(true);
|
||||
|
||||
const loadCourses = useCallback(async () => {
|
||||
setLoadingCourses(true);
|
||||
@@ -446,6 +452,30 @@ export default function PostLinkPicker({
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const loadBooking = useCallback(async () => {
|
||||
if (!user?._id) {
|
||||
setBookingConfig(null);
|
||||
setLoadingBooking(false);
|
||||
return;
|
||||
}
|
||||
setLoadingBooking(true);
|
||||
try {
|
||||
// یه کاربر حداکثر یه کانفیگ رزرو active داره — همون endpoint عمومی رو روی خودمون
|
||||
// صدا میزنیم، نیازی به endpoint جدا برای «کانفیگ خودم» نیست
|
||||
const response = await request<{ config?: { name: string } }>(
|
||||
"GET",
|
||||
`/bookings/public/${user._id}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setBookingConfig(response?.config || null);
|
||||
} catch {
|
||||
setBookingConfig(null);
|
||||
} finally {
|
||||
setLoadingBooking(false);
|
||||
}
|
||||
}, [request, user?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCourses();
|
||||
}, [loadCourses]);
|
||||
@@ -462,6 +492,10 @@ export default function PostLinkPicker({
|
||||
void loadShops();
|
||||
}, [loadShops]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBooking();
|
||||
}, [loadBooking]);
|
||||
|
||||
const courseItems = courses
|
||||
.map((c) => ({
|
||||
_id: String(c._id || c.id || ""),
|
||||
@@ -486,6 +520,13 @@ export default function PostLinkPicker({
|
||||
}))
|
||||
.filter((b) => b._id);
|
||||
|
||||
// برخلاف بقیه، _id اینجا عمداً خودِ userId مالکه (نه شناسهی کانفیگ رزرو) — چون صفحهی
|
||||
// عمومی رزرو با userId مسیردهی میشه؛ کاربر همیشه فقط کانفیگ خودش رو لینک میکنه
|
||||
const bookingItems =
|
||||
bookingConfig && user?._id
|
||||
? [{ _id: String(user._id), title: bookingConfig.name || t("posts.defaultBookingTitle") }]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-neutral-500">{t("posts.linkHint")}</p>
|
||||
@@ -542,6 +583,17 @@ export default function PostLinkPicker({
|
||||
onShopProductsChange(toggleLink(selectedShopProducts, "shopproduct", product))
|
||||
}
|
||||
/>
|
||||
<LinkSection
|
||||
label={t("posts.booking")}
|
||||
emptyText={t("posts.noBooking")}
|
||||
loading={loadingBooking}
|
||||
items={bookingItems}
|
||||
selectedIds={selectedBooking.map((p) => p._id)}
|
||||
onToggle={(item) =>
|
||||
onBookingChange(toggleLink(selectedBooking, "booking", item))
|
||||
}
|
||||
fallbackIcon="calendar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import ReelsPostActions from "./ReelsPostActions";
|
||||
import ReelsImageCarousel from "./ReelsImageCarousel";
|
||||
import PostCommentsContent from "./PostCommentsContent";
|
||||
import ExpandableCaption from "./ExpandableCaption";
|
||||
import StoryRing from "@/components/stories/StoryRing";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
import { MutedContext } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
@@ -101,7 +101,7 @@ export default function ReelsPostCard({
|
||||
const user = useUserById(user_id);
|
||||
const storyMeta = useStoryMetaForUser(user_id);
|
||||
const profile_image = user?.profile_image;
|
||||
const is_verified = user?.is_verified;
|
||||
const verify_badge = user?.verify_badge;
|
||||
|
||||
const [captionExpanded, setCaptionExpanded] = useState(false);
|
||||
const orderedCaption = reorderCaptionLinksToEnd(caption || "");
|
||||
@@ -524,6 +524,7 @@ export default function ReelsPostCard({
|
||||
<StoryRing
|
||||
hasStory={storyMeta.hasStory}
|
||||
hasUnviewed={storyMeta.hasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(verify_badge)}
|
||||
rounded="full"
|
||||
avatarSize={40}
|
||||
>
|
||||
@@ -549,7 +550,7 @@ export default function ReelsPostCard({
|
||||
<div className="min-w-0 flex-1 text-white">
|
||||
<div className="flex items-center gap-1 truncate text-sm font-semibold">
|
||||
<span className="truncate">{user_name}</span>
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-white/80">
|
||||
{first_name} {last_name}
|
||||
|
||||
@@ -22,6 +22,8 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
className="ml-2 md:h-[120px] md:w-[120px]"
|
||||
userId={creator?._id}
|
||||
verifyBadge={creator?.verify_badge}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -33,7 +35,7 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{creator?.user_name}
|
||||
<VerificationBadge isVerified={creator?.is_verified} />
|
||||
<VerificationBadge verifyBadge={creator?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,8 @@ function ProjectRequests({
|
||||
>
|
||||
<UserInfo
|
||||
first_name={item?.user?.first_name}
|
||||
is_verified={item?.user?.is_verified}
|
||||
is_verified={item?.user?.verify_badge}
|
||||
userId={item?.user?._id}
|
||||
profile_image={item?.user?.profile_image}
|
||||
user_level={item?.user?.user_level}
|
||||
last_name={item?.user?.last_name}
|
||||
|
||||
@@ -79,7 +79,8 @@ function ProjectRequestsAction({
|
||||
>
|
||||
<UserInfo
|
||||
first_name={item?.user?.first_name}
|
||||
is_verified={item?.user?.is_verified}
|
||||
is_verified={item?.user?.verify_badge}
|
||||
userId={item?.user?._id}
|
||||
profile_image={item?.user?.profile_image}
|
||||
user_level={item?.user?.user_level}
|
||||
last_name={item?.user?.last_name}
|
||||
@@ -143,7 +144,8 @@ function ProjectRequestsAction({
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center gap-4 pt-4">
|
||||
<UserInfo
|
||||
first_name={itemToAccept?.user?.first_name}
|
||||
is_verified={itemToAccept?.user?.is_verified}
|
||||
is_verified={itemToAccept?.user?.verify_badge}
|
||||
userId={itemToAccept?.user?._id}
|
||||
profile_image={itemToAccept?.user?.profile_image}
|
||||
user_level={itemToAccept?.user?.user_level}
|
||||
last_name={itemToAccept?.user?.last_name}
|
||||
|
||||
@@ -21,7 +21,8 @@ function ProjectWorkroomSelectedUser({
|
||||
<div className="flex items-end w-full justify-between max-w-[320px] md:max-w-md">
|
||||
<UserInfo
|
||||
first_name={selectedUser?.first_name}
|
||||
is_verified={selectedUser?.is_verified}
|
||||
is_verified={selectedUser?.verify_badge}
|
||||
userId={selectedUser?._id}
|
||||
profile_image={selectedUser?.profile_image}
|
||||
user_level={selectedUser?.user_level}
|
||||
last_name={selectedUser?.last_name}
|
||||
|
||||
@@ -8,41 +8,30 @@ function SearchCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/users/${user?.user_name}`}
|
||||
className="w-full max-w-md mx-auto flex items-end"
|
||||
className="w-full max-w-md mx-auto flex items-center"
|
||||
>
|
||||
<UserInfo
|
||||
first_name={user?.first_name}
|
||||
is_verified={user?.is_verified}
|
||||
is_verified={user?.verify_badge}
|
||||
userId={user?._id}
|
||||
profile_image={user?.profile_image}
|
||||
user_level={user?.user_level}
|
||||
last_name={user?.last_name}
|
||||
user_name={user?.user_name}
|
||||
noLink
|
||||
trailingContent={
|
||||
<span className="flex shrink-0 items-center gap-2 text-[11px] font-normal text-neutral-500 dark:text-neutral-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="pt-[6px] text-sm leading-none">{user?.user_score ? user?.user_score : "0"}</span>
|
||||
<Image width={16} height={16} alt="star icon" src="/images/icons/star1.png" />
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="pt-[6px] text-sm leading-none">{user?.rate ? user?.rate : "0"}</span>
|
||||
<Image width={16} height={16} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<>
|
||||
<span>{user?.user_score ? user?.user_score : "0"}</span>
|
||||
<Image
|
||||
width={21}
|
||||
height={21}
|
||||
alt="star icon"
|
||||
src={`/images/icons/star1.png`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<>
|
||||
<span>{user?.rate ? user?.rate : "0"}</span>
|
||||
<Image
|
||||
width={21}
|
||||
height={21}
|
||||
alt="rate icon"
|
||||
src={`/images/icons/medal-star.png`}
|
||||
className="pb-1"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ function UserDetails() {
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
className="shrink-0 md:h-[120px] md:w-[120px]"
|
||||
userId={user?._id}
|
||||
verifyBadge={user?.verify_badge}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[#387E65]">
|
||||
@@ -43,10 +45,7 @@ function UserDetails() {
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={user?.is_verified}
|
||||
isRegister={user?.is_Register}
|
||||
/>
|
||||
<VerificationBadge verifyBadge={user?.verify_badge} />
|
||||
</span>
|
||||
{user?.mobile ? (
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
|
||||
@@ -34,7 +34,8 @@ function GiftItem({
|
||||
first_name={user?.first_name}
|
||||
last_name={user?.last_name}
|
||||
user_name={user?.user_name}
|
||||
is_verified={user?.is_verified}
|
||||
is_verified={user?.verify_badge}
|
||||
userId={user?._id}
|
||||
/>
|
||||
<div className="hidden w-full h-[1px] max-[350px]:flex dark:bg-white/30 bg-black/30"></div>
|
||||
<div className="flex flex-col items-end max-[350px]:w-full max-[350px]:flex-row max-[350px]:items-center max-[350px]:justify-between max-sm:items-center max-sm:text-[11px]">
|
||||
|
||||
@@ -47,7 +47,8 @@ function OffersItem({
|
||||
first_name={user?.first_name}
|
||||
last_name={user?.last_name}
|
||||
user_name={user?.user_name}
|
||||
is_verified={user?.is_verified}
|
||||
is_verified={user?.verify_badge}
|
||||
userId={user?._id}
|
||||
/>
|
||||
<div className="w-full h-[1px] max-[350px]:flex hidden dark:bg-white/30 bg-black/30"></div>
|
||||
<div className="flex max-[350px]:flex-row max-[350px]:w-full max-[350px]:justify-between max-[350px]:items-center flex-col items-end max-sm:text-[11px] max-sm:items-center">
|
||||
|
||||
@@ -12,6 +12,8 @@ import { REELS_SCROLL_CLASS, useReelsSnapScroll } from "@/hooks/useReelsSnapScro
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useLayoutEffect, useMemo, useRef, useState, useEffect } from "react";
|
||||
@@ -142,6 +144,46 @@ export default function ShopReelsView({
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [listings]);
|
||||
|
||||
useEffect(() => {
|
||||
const listing = listings?.[activeIndex];
|
||||
if (!listing) return;
|
||||
const token = Cookies.get("token") || "";
|
||||
if (!token) return;
|
||||
|
||||
const ownerId =
|
||||
typeof listing.shop === "object" ? (listing.shop as { _id: string })?._id : listing.shop;
|
||||
const startedAt = Date.now();
|
||||
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "shop_product",
|
||||
targetId: listing._id,
|
||||
authorId: ownerId,
|
||||
contentType: "shop_product",
|
||||
action: "view",
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
return () => {
|
||||
const dwellMs = Date.now() - startedAt;
|
||||
if (dwellMs >= 2500) {
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "shop_product",
|
||||
targetId: listing._id,
|
||||
authorId: ownerId,
|
||||
contentType: "shop_product",
|
||||
action: "dwell",
|
||||
dwellMs,
|
||||
},
|
||||
token
|
||||
);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeIndex, listings]);
|
||||
|
||||
if (listings === null) {
|
||||
return <PageLoader className="min-h-[100dvh] bg-black" />;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import StoryViewer from "@/components/stories/StoryViewer";
|
||||
import StoryRing, {
|
||||
STORY_AVATAR_SIZE,
|
||||
STORY_RING_OUTER_SIZE,
|
||||
verifyBadgeToTierColor,
|
||||
} from "@/components/stories/StoryRing";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -175,6 +176,7 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
|
||||
<StoryRing
|
||||
hasStory
|
||||
hasUnviewed={item.has_unviewed}
|
||||
tierColor={verifyBadgeToTierColor(item.user.verify_badge)}
|
||||
avatarSize={STORY_AVATAR_SIZE}
|
||||
>
|
||||
<StoryAvatar
|
||||
|
||||
@@ -6,10 +6,20 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { StoryTextOverlay } from "@/api/fetchStories";
|
||||
import { StoryOverlay, StoryTextOverlay, StoryLinkOverlay } from "@/api/fetchStories";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import StoryLinkPicker, { PickedStoryLink } from "@/components/stories/StoryLinkPicker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LINK_KIND_ICONS: Record<PickedStoryLink["link_kind"], string> = {
|
||||
pkg: "teacher",
|
||||
project: "briefcase",
|
||||
billboard: "flash-circle",
|
||||
shop: "shop",
|
||||
shopproduct: "box",
|
||||
booking: "calendar",
|
||||
};
|
||||
|
||||
const TEXT_COLORS = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
@@ -27,8 +37,8 @@ const MAX_FONT = 0.14;
|
||||
type StoryEditorProps = {
|
||||
mediaUrl: string;
|
||||
mediaType: "image" | "video";
|
||||
overlays: StoryTextOverlay[];
|
||||
onChange: (overlays: StoryTextOverlay[]) => void;
|
||||
overlays: StoryOverlay[];
|
||||
onChange: (overlays: StoryOverlay[]) => void;
|
||||
};
|
||||
|
||||
export default function StoryEditor({
|
||||
@@ -44,6 +54,7 @@ export default function StoryEditor({
|
||||
const [textModalOpen, setTextModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [linkPickerOpen, setLinkPickerOpen] = useState(false);
|
||||
|
||||
const dragState = useRef<{
|
||||
index: number;
|
||||
@@ -64,12 +75,23 @@ export default function StoryEditor({
|
||||
const updateOverlay = useCallback(
|
||||
(index: number, patch: Partial<StoryTextOverlay>) => {
|
||||
onChange(
|
||||
overlays.map((o, i) => (i === index ? { ...o, ...patch } : o))
|
||||
overlays.map((o, i) => {
|
||||
if (i !== index || o.type === "link") return o;
|
||||
return { ...o, ...patch };
|
||||
})
|
||||
);
|
||||
},
|
||||
[overlays, onChange]
|
||||
);
|
||||
|
||||
// فقط جابجایی — روی هر دو نوع اورلی (متن/لینک) یکسان کار میکنه، چون هر دو x/y دارن
|
||||
const updateOverlayPosition = useCallback(
|
||||
(index: number, x: number, y: number) => {
|
||||
onChange(overlays.map((o, i) => (i === index ? { ...o, x, y } : o)));
|
||||
},
|
||||
[overlays, onChange]
|
||||
);
|
||||
|
||||
const removeOverlay = useCallback(
|
||||
(index: number) => {
|
||||
onChange(overlays.filter((_, i) => i !== index));
|
||||
@@ -82,7 +104,8 @@ export default function StoryEditor({
|
||||
if (index === null) {
|
||||
setDraftText("");
|
||||
} else {
|
||||
setDraftText(overlays[index]?.text ?? "");
|
||||
const overlay = overlays[index];
|
||||
setDraftText(overlay && overlay.type !== "link" ? overlay.text : "");
|
||||
}
|
||||
setEditingIndex(index);
|
||||
setTextModalOpen(true);
|
||||
@@ -136,13 +159,28 @@ export default function StoryEditor({
|
||||
state.moved = true;
|
||||
const x = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
|
||||
updateOverlay(state.index, { x, y });
|
||||
updateOverlayPosition(state.index, x, y);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
dragState.current = null;
|
||||
};
|
||||
|
||||
const addLinkOverlay = (item: PickedStoryLink) => {
|
||||
const next: StoryLinkOverlay = {
|
||||
type: "link",
|
||||
link_kind: item.link_kind,
|
||||
link_id: item.link_id,
|
||||
link_title: item.link_title,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
rotation: 0,
|
||||
};
|
||||
onChange([...overlays, next]);
|
||||
setSelected(overlays.length);
|
||||
setLinkPickerOpen(false);
|
||||
};
|
||||
|
||||
const selectedOverlay =
|
||||
selected !== null ? overlays[selected] : null;
|
||||
|
||||
@@ -175,6 +213,44 @@ export default function StoryEditor({
|
||||
)}
|
||||
|
||||
{overlays.map((o, index) => {
|
||||
if (o.type === "link") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "0.4em 0.7em",
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
maxWidth: "80%",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
outline:
|
||||
selected === index ? "2px dashed rgba(255,255,255,0.7)" : "none",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
<BoldIcon name={LINK_KIND_ICONS[o.link_kind]} size={14} tinted className="text-white" />
|
||||
<span className="truncate">{o.link_title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fontPx = Math.max(
|
||||
10,
|
||||
o.font_size * (containerSize.width || 300)
|
||||
@@ -217,22 +293,54 @@ export default function StoryEditor({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add-text FAB */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTextEditor(null);
|
||||
}}
|
||||
className="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="add" size={16} tinted className="text-white" />
|
||||
{t("stories.addText")}
|
||||
</button>
|
||||
{/* Add-text / Add-link FABs */}
|
||||
<div className="absolute right-2 top-2 z-10 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTextEditor(null);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="add" size={16} tinted className="text-white" />
|
||||
{t("stories.addText")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLinkPickerOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="link" size={16} tinted className="text-white" />
|
||||
{t("stories.attachLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkPickerOpen && (
|
||||
<StoryLinkPicker onSelect={addLinkOverlay} onClose={() => setLinkPickerOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Toolbar for selected overlay */}
|
||||
{selectedOverlay && (
|
||||
{selectedOverlay && selectedOverlay.type === "link" && (
|
||||
<div className="mx-auto flex w-full max-w-[340px] items-center justify-between rounded-xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<span className="text-xs font-semibold text-neutral-500">
|
||||
{selectedOverlay.link_title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selected !== null && removeOverlay(selected)}
|
||||
className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-red-950/40"
|
||||
>
|
||||
{t("stories.remove")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOverlay && selectedOverlay.type !== "link" && (
|
||||
<div className="mx-auto w-full max-w-[340px] space-y-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">
|
||||
|
||||
179
src/components/stories/StoryLinkPicker.tsx
Normal file
179
src/components/stories/StoryLinkPicker.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Course, IAdvertising, IShopProductListing, Project } from "@/types/types";
|
||||
import type { PostLinkKind } from "@/lib/postLinkCaption";
|
||||
import { cn } from "@/lib/utils";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MyShop = { _id: string; name: string };
|
||||
|
||||
export type PickedStoryLink = {
|
||||
link_kind: PostLinkKind;
|
||||
link_id: string;
|
||||
link_title: string;
|
||||
};
|
||||
|
||||
type Item = { kind: PostLinkKind; _id: string; title: string; icon: string };
|
||||
|
||||
// پیکر مستقل و سبک برای انتخاب یه آیتم و انداختنش روی استوری بهعنوان اورلی قابلجابجایی —
|
||||
// عمداً جدا از PostLinkPicker.tsx نگه داشته شده (نه ریفکتور اون) تا پیکر پست که همین الان
|
||||
// درست کار میکنه، دستنخورده بمونه؛ چندتا فراخوانی فچ مشابه تکرار شده، معاملهی قابلقبولیه
|
||||
export default function StoryLinkPicker({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
onSelect: (item: PickedStoryLink) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const user = useUser();
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const collected: Item[] = [];
|
||||
|
||||
try {
|
||||
const res = await request<{ data?: { courses?: Course[] }; courses?: Course[] }>(
|
||||
"GET",
|
||||
"/academy/academy/get/course?page=1&limit=100",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
const list = res?.data?.courses ?? res?.courses ?? [];
|
||||
list.forEach((c) => {
|
||||
const id = String(c._id || c.id || "");
|
||||
if (id) collected.push({ kind: "pkg", _id: id, title: c.cuorse_name || t("posts.defaultPackageTitle"), icon: "teacher" });
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
if (user?._id) {
|
||||
const res = await request<{ data?: { projects?: Project[] }; projects?: Project[] }>(
|
||||
"GET",
|
||||
`/projects/user-projects?user_id=${user._id}&page=1&limit=100&linkable=1`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
const list = res?.data?.projects ?? res?.projects ?? [];
|
||||
list.forEach((p) => {
|
||||
if (p._id) collected.push({ kind: "project", _id: String(p._id), title: p.title || t("posts.defaultProjectTitle"), icon: "briefcase" });
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request<{ advertisings?: IAdvertising[]; data?: { advertisings?: IAdvertising[] } }>(
|
||||
"GET",
|
||||
"/advertising/user-advertising?page=1&limit=100",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
const list = res?.advertisings ?? res?.data?.advertisings ?? [];
|
||||
list.forEach((b) => {
|
||||
if (b._id) collected.push({ kind: "billboard", _id: String(b._id), title: b.title || t("posts.defaultBillboardTitle"), icon: "flash-circle" });
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request<{ shops?: MyShop[] }>("GET", "/shops/mine", null, { noToast: true });
|
||||
const shops = Array.isArray(res?.shops) ? res.shops : [];
|
||||
for (const shop of shops) {
|
||||
collected.push({ kind: "shop", _id: shop._id, title: shop.name, icon: "shop" });
|
||||
try {
|
||||
const productsRes = await request<{ docs?: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shop._id}&limit=100`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
(productsRes?.docs || []).forEach((p) => {
|
||||
collected.push({ kind: "shopproduct", _id: String(p._id), title: p.title, icon: "box" });
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
if (user?._id) {
|
||||
const res = await request<{ config?: { name: string } }>(
|
||||
"GET",
|
||||
`/bookings/public/${user._id}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
if (res?.config) {
|
||||
collected.push({
|
||||
kind: "booking",
|
||||
_id: String(user._id),
|
||||
title: res.config.name || t("posts.defaultBookingTitle"),
|
||||
icon: "calendar",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
setItems(collected);
|
||||
setLoading(false);
|
||||
}, [request, user?._id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-3 text-center text-sm font-semibold text-neutral-700 dark:text-neutral-200">
|
||||
{t("stories.attachLink")}
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="py-6 text-center text-xs text-neutral-400">{t("common.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-neutral-400">{t("stories.noLinkableItems")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}-${item._id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelect({ link_kind: item.kind, link_id: item._id, link_title: item.title })
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-right transition",
|
||||
"hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
<BoldIcon name={item.icon} size={18} className="dark:invert" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{item.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,33 @@ export const STORY_RING_PX = 2.5;
|
||||
export const STORY_AVATAR_SIZE = 56;
|
||||
export const STORY_RING_OUTER_SIZE = STORY_AVATAR_SIZE + STORY_RING_PX * 2;
|
||||
|
||||
export const TIER_RING_COLORS = {
|
||||
blue: "#2196EB",
|
||||
green: "#22C55E",
|
||||
gold: "#EAB308",
|
||||
} as const;
|
||||
|
||||
export type StoryRingTierColor = "blue" | "green" | "gold" | null;
|
||||
|
||||
export function verifyBadgeToTierColor(verifyBadge?: string | null): StoryRingTierColor {
|
||||
if (verifyBadge === "blue" || verifyBadge === "green" || verifyBadge === "gold") {
|
||||
return verifyBadge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface StoryRingProps {
|
||||
hasStory?: boolean;
|
||||
hasUnviewed?: boolean;
|
||||
forceGradient?: boolean;
|
||||
/** When set (blue/green/gold), overrides the default gradient/gray ring with a flat tier color. Gray/none tiers should pass null/undefined to keep default behavior. */
|
||||
tierColor?: StoryRingTierColor;
|
||||
rounded?: "full" | "xl";
|
||||
avatarSize?: number;
|
||||
avatarRadius?: number;
|
||||
animated?: boolean;
|
||||
/** Applied to the outer wrapper (e.g. margin/shrink utilities) — never to the clipped inner avatar box. */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -22,16 +41,14 @@ export default function StoryRing({
|
||||
hasStory = false,
|
||||
hasUnviewed = false,
|
||||
forceGradient = false,
|
||||
tierColor = null,
|
||||
rounded = "full",
|
||||
avatarSize,
|
||||
avatarRadius,
|
||||
animated = true,
|
||||
className,
|
||||
children,
|
||||
}: StoryRingProps) {
|
||||
if (!hasStory) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isXl = rounded === "xl";
|
||||
const resolvedAvatarSize =
|
||||
avatarSize ?? (isXl ? undefined : STORY_AVATAR_SIZE);
|
||||
@@ -40,7 +57,7 @@ export default function StoryRing({
|
||||
(isXl ? 12 : resolvedAvatarSize ? resolvedAvatarSize / 2 : undefined);
|
||||
const innerRadius =
|
||||
cornerRadius != null
|
||||
? Math.max(0, cornerRadius - STORY_RING_PX)
|
||||
? Math.max(0, cornerRadius - (hasStory ? STORY_RING_PX : 0))
|
||||
: undefined;
|
||||
|
||||
const outerRounded = isXl ? "rounded-xl" : "rounded-full";
|
||||
@@ -51,42 +68,57 @@ export default function StoryRing({
|
||||
? "rounded-[calc(0.75rem-2.5px)]"
|
||||
: "rounded-full";
|
||||
|
||||
// When there's no story, the ring layer isn't drawn — keep the total footprint
|
||||
// exactly equal to resolvedAvatarSize (matching the old "render bare children"
|
||||
// behavior) instead of the ring-inclusive size, so call sites whose surrounding
|
||||
// layout was built around "no story = original size" don't grow unexpectedly.
|
||||
const ringInset = hasStory ? STORY_RING_PX : 0;
|
||||
const outerSize =
|
||||
resolvedAvatarSize != null
|
||||
? resolvedAvatarSize + STORY_RING_PX * 2
|
||||
? resolvedAvatarSize + (hasStory ? STORY_RING_PX * 2 : 0)
|
||||
: undefined;
|
||||
|
||||
const gradient =
|
||||
"conic-gradient(from 210deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)";
|
||||
|
||||
const live = hasUnviewed || forceGradient;
|
||||
const ringBg = live ? gradient : "#a3a3a3";
|
||||
const ringBg = tierColor
|
||||
? TIER_RING_COLORS[tierColor]
|
||||
: live
|
||||
? gradient
|
||||
: "#a3a3a3";
|
||||
|
||||
// Always render the same wrapper structure (same total size, same rounding)
|
||||
// regardless of hasStory — only the ring-color layer is conditional. This keeps
|
||||
// the box model identical between "has story" and "no story" avatars in the same
|
||||
// row/list, avoiding any vertical misalignment when only some rows show a ring.
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex shrink-0 items-center justify-center"
|
||||
className={cn("relative inline-flex shrink-0 items-center justify-center", className)}
|
||||
style={
|
||||
outerSize != null
|
||||
? { width: outerSize, height: outerSize }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
outerRounded,
|
||||
live && animated && "story-ring-live"
|
||||
)}
|
||||
style={{ background: ringBg }}
|
||||
aria-hidden
|
||||
/>
|
||||
{hasStory && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
outerRounded,
|
||||
!tierColor && live && animated && "story-ring-live"
|
||||
)}
|
||||
style={{ background: ringBg }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-[1] flex items-center justify-center overflow-hidden bg-white leading-none dark:bg-neutral-950",
|
||||
innerRounded
|
||||
)}
|
||||
style={{
|
||||
margin: STORY_RING_PX,
|
||||
margin: ringInset,
|
||||
...(innerRadius != null ? { borderRadius: innerRadius } : {}),
|
||||
...(resolvedAvatarSize != null
|
||||
? {
|
||||
|
||||
@@ -10,7 +10,17 @@ import React, {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import toast from "react-hot-toast";
|
||||
import { StoryFeedUser, StoryTextOverlay, StoryViewerUser } from "@/api/fetchStories";
|
||||
import { StoryFeedUser, StoryOverlay, StoryViewerUser } from "@/api/fetchStories";
|
||||
import { buildPostLinkPath, PostLinkKind } from "@/lib/postLinkCaption";
|
||||
|
||||
const LINK_KIND_ICONS: Record<PostLinkKind, string> = {
|
||||
pkg: "teacher",
|
||||
project: "briefcase",
|
||||
billboard: "flash-circle",
|
||||
shop: "shop",
|
||||
shopproduct: "box",
|
||||
booking: "calendar",
|
||||
};
|
||||
import {
|
||||
markStoryViewed,
|
||||
updateStoryOverlays,
|
||||
@@ -21,7 +31,7 @@ import {
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
import StoryRing from "@/components/stories/StoryRing";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import SendStoryModal from "@/components/stories/SendStoryModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -49,10 +59,10 @@ export default function StoryViewer({
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftOverlays, setDraftOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [draftOverlays, setDraftOverlays] = useState<StoryOverlay[]>([]);
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [overlayOverrides, setOverlayOverrides] = useState<
|
||||
Record<string, StoryTextOverlay[]>
|
||||
Record<string, StoryOverlay[]>
|
||||
>({});
|
||||
const [likedStories, setLikedStories] = useState<Record<string, boolean>>({});
|
||||
const [commentText, setCommentText] = useState("");
|
||||
@@ -384,6 +394,7 @@ export default function StoryViewer({
|
||||
<StoryRing
|
||||
hasStory
|
||||
hasUnviewed={headerHasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(currentUser.user.verify_badge)}
|
||||
rounded="full"
|
||||
avatarSize={32}
|
||||
>
|
||||
@@ -463,33 +474,72 @@ export default function StoryViewer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text overlays */}
|
||||
{currentOverlays.map((o, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
color: o.color,
|
||||
fontSize: `${Math.max(10, o.font_size * (mediaWidth || 380))}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
borderRadius: o.background ? "0.35em" : 0,
|
||||
maxWidth: "90%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
textShadow: o.background ? "none" : "0 1px 3px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
))}
|
||||
{/* اورلیهای متن و لینک — لینک برخلاف متن باید کلیکپذیر باشه، پس pointerEvents:auto
|
||||
و z-index بالاتر از تپزونهای قبلی/بعدی (z-10) داره، با stopPropagation که با
|
||||
پاز/ادامهشدن استوری (روی تپزونها) تداخل نکنه */}
|
||||
{currentOverlays.map((o, idx) => {
|
||||
if (o.type === "link") {
|
||||
return (
|
||||
<Link
|
||||
key={idx}
|
||||
href={buildPostLinkPath(o.link_kind, o.link_id, o.link_title) || "#"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
zIndex: 20,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "0.4em 0.7em",
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
maxWidth: "80%",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<BoldIcon name={LINK_KIND_ICONS[o.link_kind]} size={14} tinted className="text-white" />
|
||||
<span className="truncate">{o.link_title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
color: o.color,
|
||||
fontSize: `${Math.max(10, o.font_size * (mediaWidth || 380))}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
borderRadius: o.background ? "0.35em" : 0,
|
||||
maxWidth: "90%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
textShadow: o.background ? "none" : "0 1px 3px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tap zones */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
@@ -39,6 +40,7 @@ function AlertDialogOverlay({
|
||||
"glass-modal-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-overlay")}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -57,6 +59,7 @@ function AlertDialogContent({
|
||||
"glass-modal-panel glass-modal-panel--center data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-panel")}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -32,6 +33,7 @@ function DialogClose({
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
@@ -41,6 +43,7 @@ function DialogOverlay({
|
||||
"glass-modal-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
style={{ ...glassBackdropStyle("glass-modal-overlay"), ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -63,6 +66,7 @@ function DialogContent({
|
||||
"glass-modal-panel glass-modal-panel--center data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-panel")}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
@@ -40,6 +41,7 @@ function DrawerOverlay({
|
||||
"glass-modal-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-overlay")}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -63,6 +65,7 @@ function DrawerContent({
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-panel")}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
@@ -39,6 +40,7 @@ function SheetOverlay({
|
||||
"glass-modal-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-overlay")}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -69,6 +71,7 @@ function SheetContent({
|
||||
"glass-modal-panel--sheet data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto",
|
||||
className
|
||||
)}
|
||||
style={glassBackdropStyle("glass-modal-panel")}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,11 +15,12 @@ export function buildFullPostCaption(
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
shopProducts: SelectedPostLink[] = [],
|
||||
bookings: SelectedPostLink[] = []
|
||||
): string {
|
||||
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
|
||||
const linkHeader = buildPostLinkCaptionHeader(
|
||||
[...packages, ...projects, ...billboards, ...shops, ...shopProducts],
|
||||
[...packages, ...projects, ...billboards, ...shops, ...shopProducts, ...bookings],
|
||||
lang
|
||||
);
|
||||
return [description.trim(), tagLine, linkHeader].filter(Boolean).join("\n");
|
||||
@@ -33,7 +34,8 @@ export function countPostCaptionLength(
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
shopProducts: SelectedPostLink[] = [],
|
||||
bookings: SelectedPostLink[] = []
|
||||
): number {
|
||||
return buildFullPostCaption(
|
||||
description,
|
||||
@@ -43,6 +45,7 @@ export function countPostCaptionLength(
|
||||
billboards,
|
||||
lang,
|
||||
shops,
|
||||
shopProducts
|
||||
shopProducts,
|
||||
bookings
|
||||
).length;
|
||||
}
|
||||
|
||||
28
src/lib/glassBackdrop.ts
Normal file
28
src/lib/glassBackdrop.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* Turbopack's Tailwind v4 (Lightning CSS) pipeline silently strips hand-authored
|
||||
* `backdrop-filter`/`-webkit-backdrop-filter` declarations from globals.css classes
|
||||
* in this project (confirmed via computed-style + compiled-stylesheet inspection —
|
||||
* the class survives with every other declaration except backdrop-filter).
|
||||
* Inline styles bypass that pipeline entirely, so every glass-* class gets its
|
||||
* matching blur/saturate re-applied here as a companion inline style.
|
||||
*/
|
||||
export const GLASS_BACKDROP: Record<string, CSSProperties> = {
|
||||
"glass-panel": { backdropFilter: "blur(20px) saturate(180%)", WebkitBackdropFilter: "blur(20px) saturate(180%)" },
|
||||
"chat-header-glass": { backdropFilter: "blur(20px) saturate(180%)", WebkitBackdropFilter: "blur(20px) saturate(180%)" },
|
||||
"chat-timer-glass": { backdropFilter: "blur(20px) saturate(180%)", WebkitBackdropFilter: "blur(20px) saturate(180%)" },
|
||||
"chat-timer-badge-glass": { backdropFilter: "blur(10px) saturate(160%)", WebkitBackdropFilter: "blur(10px) saturate(160%)" },
|
||||
"glass-chat-input": { backdropFilter: "blur(24px) saturate(200%)", WebkitBackdropFilter: "blur(24px) saturate(200%)" },
|
||||
"glass-panel-strong": { backdropFilter: "blur(24px) saturate(1.4)", WebkitBackdropFilter: "blur(24px) saturate(1.4)" },
|
||||
"comments-modal-glass": { backdropFilter: "blur(28px) saturate(1.5)", WebkitBackdropFilter: "blur(28px) saturate(1.5)" },
|
||||
"tab-active-glass": { backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)" },
|
||||
"glass-modal-overlay": { backdropFilter: "blur(20px) saturate(120%)", WebkitBackdropFilter: "blur(20px) saturate(120%)" },
|
||||
"glass-modal-overlay--dark": { backdropFilter: "blur(24px) saturate(110%)", WebkitBackdropFilter: "blur(24px) saturate(110%)" },
|
||||
"glass-modal-panel": { backdropFilter: "blur(28px) saturate(180%)", WebkitBackdropFilter: "blur(28px) saturate(180%)" },
|
||||
};
|
||||
|
||||
/** Returns the inline backdrop-filter style for a known glass-* class name, merged if multiple apply. */
|
||||
export function glassBackdropStyle(...classNames: string[]): CSSProperties {
|
||||
return Object.assign({}, ...classNames.map((c) => GLASS_BACKDROP[c]).filter(Boolean));
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
|
||||
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard" | "shop" | "shopproduct";
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard" | "shop" | "shopproduct" | "booking";
|
||||
|
||||
/** مارکر لینک داخل کپشن */
|
||||
export const POST_LINK_TOKEN_REGEX =
|
||||
/\[\[(pkg|project|billboard|shop|shopproduct):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
/\[\[(pkg|project|billboard|shop|shopproduct|booking):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
|
||||
/** @deprecated use POST_LINK_TOKEN_REGEX */
|
||||
export const POST_PACKAGE_TOKEN_REGEX =
|
||||
@@ -21,6 +21,7 @@ const FA_LINK_LABELS = {
|
||||
billboard: "بیلبورد",
|
||||
shop: "فروشگاه",
|
||||
shopproduct: "کالا",
|
||||
booking: "رزرو نوبت",
|
||||
} as const;
|
||||
|
||||
const EN_LINK_LABELS = {
|
||||
@@ -29,6 +30,7 @@ const EN_LINK_LABELS = {
|
||||
billboard: "Billboard",
|
||||
shop: "Shop",
|
||||
shopproduct: "Product",
|
||||
booking: "Booking",
|
||||
} as const;
|
||||
|
||||
export function getPostLinkLabels(lang: AppLanguage = DEFAULT_LANGUAGE) {
|
||||
@@ -61,6 +63,8 @@ export function buildPostLinkPath(
|
||||
return `/shops/profile/${id}`;
|
||||
case "shopproduct":
|
||||
return `/shops/listing/${id}`;
|
||||
case "booking":
|
||||
return `/booking/${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +91,14 @@ export function buildPostLinkCaptionHeader(
|
||||
billboard: [],
|
||||
shop: [],
|
||||
shopproduct: [],
|
||||
booking: [],
|
||||
};
|
||||
for (const link of list) {
|
||||
groups[link.kind].push(link);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
(["pkg", "project", "billboard", "shop", "shopproduct"] as const).forEach((kind) => {
|
||||
(["pkg", "project", "billboard", "shop", "shopproduct", "booking"] as const).forEach((kind) => {
|
||||
if (!groups[kind].length) return;
|
||||
const tokens = groups[kind]
|
||||
.map((l) => buildPostLinkCaptionToken(l.kind, l._id, l.title))
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"removeRange": "Remove",
|
||||
"addRange": "Add range",
|
||||
"holidaysTitle": "Holidays",
|
||||
"weeklyRepeatCountTitle": "Weekly repeat",
|
||||
"weeklyRepeatCountHint": "How many weeks should this schedule repeat? Leave empty for unlimited. When it ends, the booking is automatically deactivated and you'll be notified to renew it yourself.",
|
||||
"weeklyRepeatCountPlaceholder": "e.g. 3",
|
||||
"weeklyRepeatCountInvalid": "Invalid weekly repeat count",
|
||||
"depositTitle": "Require deposit",
|
||||
"depositHint": "The buyer must pay 30% of the service price online to confirm the booking.",
|
||||
"previewTitle": "Booking preview",
|
||||
@@ -904,6 +908,9 @@
|
||||
"noProject": "No projects yet",
|
||||
"billboard": "Billboard",
|
||||
"noBillboard": "No billboards yet",
|
||||
"booking": "Booking",
|
||||
"noBooking": "No active booking page yet",
|
||||
"defaultBookingTitle": "My booking",
|
||||
"shop": "Shop",
|
||||
"noShop": "No shops yet",
|
||||
"viewProducts": "View products",
|
||||
@@ -938,6 +945,8 @@
|
||||
"nextSuggestion": "Next suggestion",
|
||||
"viewFollowingPosts": "View following feed",
|
||||
"newPost": "New post",
|
||||
"provinceOptional": "Province (optional)",
|
||||
"cityOptional": "City (optional)",
|
||||
"newStory": "New story",
|
||||
"shareButton": "Share",
|
||||
"modeImage": "Photo",
|
||||
@@ -967,6 +976,8 @@
|
||||
"stories": {
|
||||
"yourStory": "Your story",
|
||||
"addStory": "Add story",
|
||||
"attachLink": "Attach a link",
|
||||
"noLinkableItems": "Nothing to link yet",
|
||||
"you": "You",
|
||||
"viewStoryAria": "View story",
|
||||
"follow": "Follow",
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"removeRange": "حذف",
|
||||
"addRange": "افزودن بازه",
|
||||
"holidaysTitle": "روزهای تعطیل",
|
||||
"weeklyRepeatCountTitle": "تکرار هفته",
|
||||
"weeklyRepeatCountHint": "این ساعات کاری چند هفته تکرار بشه؟ خالی بذارید یعنی نامحدود. بعد از پایان مهلت، رزرو بهصورت خودکار غیرفعال میشه و به شما اطلاع داده میشود تا خودتان تمدیدش کنید.",
|
||||
"weeklyRepeatCountPlaceholder": "مثلاً 3",
|
||||
"weeklyRepeatCountInvalid": "تعداد تکرار هفته نامعتبر است",
|
||||
"depositTitle": "دریافت بیعانه",
|
||||
"depositHint": "کاربر برای ثبت رزرو باید ۳۰٪ مبلغ خدمت را آنلاین پرداخت کند.",
|
||||
"previewTitle": "پیشنمایش رزرو",
|
||||
@@ -904,6 +908,9 @@
|
||||
"noProject": "پروژهای ثبت نشده",
|
||||
"billboard": "بیلبورد",
|
||||
"noBillboard": "بیلبوردی ثبت نشده",
|
||||
"booking": "رزرو نوبت",
|
||||
"noBooking": "صفحه رزرو فعالی ندارید",
|
||||
"defaultBookingTitle": "رزرو نوبت من",
|
||||
"shop": "فروشگاه",
|
||||
"noShop": "فروشگاهی ثبت نشده",
|
||||
"viewProducts": "نمایش کالاها",
|
||||
@@ -938,6 +945,8 @@
|
||||
"nextSuggestion": "پیشنهاد بعدی",
|
||||
"viewFollowingPosts": "مشاهده پستهای دنبالشوندگان",
|
||||
"newPost": "پست جدید",
|
||||
"provinceOptional": "استان (اختیاری)",
|
||||
"cityOptional": "شهر (اختیاری)",
|
||||
"newStory": "استوری جدید",
|
||||
"shareButton": "اشتراک",
|
||||
"modeImage": "عکس",
|
||||
@@ -967,6 +976,8 @@
|
||||
"stories": {
|
||||
"yourStory": "استوری شما",
|
||||
"addStory": "افزودن استوری",
|
||||
"attachLink": "افزودن لینک",
|
||||
"noLinkableItems": "چیزی برای لینک کردن نیست",
|
||||
"you": "شما",
|
||||
"viewStoryAria": "مشاهده استوری",
|
||||
"follow": "دنبال کردن",
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface IProfileData {
|
||||
user_name: NullStr;
|
||||
show_location: boolean;
|
||||
is_verified: NullStr;
|
||||
verify_badge?: NullStr;
|
||||
user_score: NullStr;
|
||||
rate: NullStr;
|
||||
bio: NullStr;
|
||||
@@ -225,6 +226,7 @@ export interface User {
|
||||
hair_color?: string;
|
||||
height?: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
last_name: string;
|
||||
last_post: LastPost;
|
||||
profile_image?: string;
|
||||
@@ -318,6 +320,7 @@ export interface IProjectCreator {
|
||||
user_name: string;
|
||||
profile_image: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
rate: number | null;
|
||||
user_type: string;
|
||||
city: ICity;
|
||||
@@ -398,6 +401,7 @@ export interface SelectedUser {
|
||||
expertise: string;
|
||||
first_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
last_name: string;
|
||||
profile_image: string;
|
||||
province: IProvince;
|
||||
@@ -433,6 +437,7 @@ export interface IProjectRequestUser {
|
||||
_id: string;
|
||||
first_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
last_name: string;
|
||||
profile_image: string;
|
||||
rate: string;
|
||||
@@ -584,6 +589,7 @@ export interface ISender {
|
||||
_id: string;
|
||||
first_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
last_name: string;
|
||||
profile_image: string;
|
||||
rate: string | null;
|
||||
@@ -596,6 +602,7 @@ export interface IReceiver {
|
||||
_id: string;
|
||||
first_name: string;
|
||||
is_verified: string;
|
||||
verify_badge?: string;
|
||||
last_name: string;
|
||||
profile_image: string;
|
||||
rate: string | null;
|
||||
|
||||
Reference in New Issue
Block a user