This commit is contained in:
payacom
2026-08-11 19:39:10 +03:30
parent 491040f752
commit 54623fb720
74 changed files with 1361 additions and 366 deletions

View 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

View 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

View 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

View 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

View File

@@ -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}`, {

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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]"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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} />

View File

@@ -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" />}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}
/>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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]">

View File

@@ -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">

View File

@@ -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" />;
}

View File

@@ -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

View File

@@ -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">

View 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>
);
}

View File

@@ -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
? {

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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
View 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));
}

View File

@@ -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))

View File

@@ -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",

View File

@@ -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": "دنبال کردن",

View File

@@ -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;