diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..9ddb903 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "modstagram-next-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/src/api/fetchStories.ts b/src/api/fetchStories.ts index 346eb93..91a1ccd 100644 --- a/src/api/fetchStories.ts +++ b/src/api/fetchStories.ts @@ -1,12 +1,24 @@ import { getApiBaseUrl } from "@/components/main/BaseUrl"; import type { PostLinkKind } from "@/lib/postLinkCaption"; +// اپ فعلاً فقط یه خانواده‌فونت (IRANSansX) با وزن‌های مختلف داره، نه چند تایپ‌فیسِ واقعاً +// جدا مثل اینستاگرام — پس «تغییر فونت» همون وزن‌هاست، تنها گزینه‌ی واقعاً موجود (عین RN) +export const STORY_FONT_FAMILIES = [ + { value: "light", label: "نازک" }, + { value: "regular", label: "معمولی" }, + { value: "medium", label: "متوسط" }, + { value: "bold", label: "ضخیم" }, + { value: "black", label: "خیلی ضخیم" }, +] as const; +export type StoryFontFamily = (typeof STORY_FONT_FAMILIES)[number]["value"]; + export type StoryTextOverlay = { type?: "text"; text: string; x: number; y: number; font_size: number; + font_family?: StoryFontFamily; color: string; background?: string; rotation?: number; @@ -24,7 +36,90 @@ export type StoryLinkOverlay = { rotation?: number; }; -export type StoryOverlay = StoryTextOverlay | StoryLinkOverlay; +export type StoryMentionOverlay = { + type: "mention"; + mention_user_id: string; + mention_username: string; + x: number; + y: number; + rotation?: number; + font_size: number; +}; + +export type StoryHashtagOverlay = { + type: "hashtag"; + text: string; // بدون # + x: number; + y: number; + rotation?: number; + font_size: number; +}; + +export type StoryLocationOverlay = { + type: "location"; + text: string; // نام آزاد مکان + x: number; + y: number; + rotation?: number; + font_size: number; +}; + +export type StoryStickerOverlay = { + type: "sticker"; + text: string; // خودِ ایموجی + x: number; + y: number; + rotation?: number; + font_size: number; +}; + +export type StoryOverlay = + | StoryTextOverlay + | StoryLinkOverlay + | StoryMentionOverlay + | StoryHashtagOverlay + | StoryLocationOverlay + | StoryStickerOverlay; + +export const DEFAULT_STICKER_FONT_SIZE = 0.045; + +function clampNum(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +// سقفِ ایمنِ کلی — اگه یه استوریِ قدیمی (مثلاً قبل از رفعِ باگِ ناهم‌خوانیِ نسبتِ +// تصویرِ ادیتور/نمایشگر) مقدارِ font_size خیلی بزرگی ذخیره کرده باشه، رندرِ اون مستقیم +// توی ادیتور می‌تونه بدشکل/بی‌اندازه بشه — قبل از باز کردنِ ادیتور برای ویرایش، همه‌ی +// این مقادیر یه‌بار سالم‌سازی می‌شن (لینک font_size ندارد، دست‌نخورده می‌ماند) +const SAFE_FONT_SIZE_MIN = 0.02; +const SAFE_FONT_SIZE_MAX = 0.25; + +export function sanitizeOverlaysForEditing(overlays: StoryOverlay[]): StoryOverlay[] { + return overlays.map((o) => { + const clamped = { + ...o, + x: clampNum(Number.isFinite(o.x) ? o.x : 0.5, 0, 1), + y: clampNum(Number.isFinite(o.y) ? o.y : 0.5, 0, 1), + }; + if (o.type === "link") return clamped; + return { + ...clamped, + font_size: clampNum( + Number.isFinite(o.font_size) ? o.font_size : DEFAULT_STICKER_FONT_SIZE, + SAFE_FONT_SIZE_MIN, + SAFE_FONT_SIZE_MAX, + ), + }; + }); +} +export const DEFAULT_EMOJI_FONT_SIZE = 0.09; + +// عین RN — ست کوچیک و پرکاربرد، نه کل کیبورد ایموجی +export const STORY_EMOJI_PRESETS = [ + "❤️", "🔥", "😂", "😍", "😮", "😢", "👏", "🎉", + "✨", "💯", "😎", "🙏", "👍", "🥳", "😘", "😭", + "🤔", "😅", "💔", "⭐", "🌹", "🎶", "☀️", "🌙", +]; export type StoryItem = { _id: string; diff --git a/src/app/(auth)/verify/avatar/page.tsx b/src/app/(auth)/verify/avatar/page.tsx index a56f073..682456d 100644 --- a/src/app/(auth)/verify/avatar/page.tsx +++ b/src/app/(auth)/verify/avatar/page.tsx @@ -21,8 +21,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell"; function AvatarPage() { const { t } = useTranslation("common"); const router = useRouter(); - const { request, loading } = useAxios(); - const [userDetail, setUserDetail] = useState(null); + const { request } = useAxios(); const [avatar, setAvatar] = useState(null); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); @@ -35,7 +34,6 @@ function AvatarPage() { const fetchData = async () => { try { const response = await request<{ user: IProfileData }>("GET", "/profile"); - setUserDetail(response?.user || null); setAvatar(response?.user?.profile_image || null); } catch (err) { console.log(err); diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx index 2b606cd..47d8d17 100644 --- a/src/app/(auth)/verify/confirm/page.tsx +++ b/src/app/(auth)/verify/confirm/page.tsx @@ -3,7 +3,6 @@ import Container from "@/components/elements/Container"; import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import Modal from "@/components/elements/Modal"; import Link from "next/link"; @@ -19,7 +18,6 @@ function Confirm() { const { t } = useTranslation("common"); const user = useUser(); const { request } = useAxios(); - const router = useRouter(); const [showModal, setShowModal] = useState(false); const [navigating, setNavigating] = useState(false); const [verifiedStatus, setVerifiedStatus] = useState(null); diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx index 2eddca2..9f91497 100644 --- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx +++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx @@ -18,7 +18,7 @@ import { Star, Phone, } from "lucide-react"; -import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles"; +import { btnPrimary } from "@/lib/ui/buttonStyles"; import { cn } from "@/lib/utils"; import { Course } from "@/types/types"; import useAxios from "@/hooks/useAxios"; @@ -97,7 +97,6 @@ interface MediaFile { } export default function CourseDetail({ - courseId, initialIsPurchased = false, }: CourseDetailProps) { const { t } = useTranslation("common"); @@ -105,7 +104,6 @@ export default function CourseDetail({ const [isPurchased, setIsPurchased] = useState(initialIsPurchased); const [purchasing, setPurchasing] = useState(false); const [showContactInfoModal, setShowContactInfoModal] = useState(false); - const [videos] = useState(MOCK_VIDEOS); const [course] = useState(MOCK_COURSE); const pathname = usePathname(); @@ -130,7 +128,6 @@ export default function CourseDetail({ const [usersCache, setUsersCache] = useState>({}); const [videoFields, setVideoFields] = useState([]); - const [newScore, setNewScore] = useState(0); const [rating, setRating] = useState(0); const containerRef = useRef(null); @@ -240,11 +237,9 @@ export default function CourseDetail({ }); const average = sum / count; - const averagesum = sum *1; const roundedRating = Math.round(average * 10) / 10 setRating(roundedRating); - setNewScore(averagesum); if (response?.success) { let newComments = response.data.comments; @@ -605,9 +600,6 @@ export default function CourseDetail({ courseContactInfo?.instagramLink ); - const freeVideosCount = videos.filter((v) => v.is_free).length; - const paidVideosCount = videos.filter((v) => !v.is_free).length; - // کامپوننت نمایش ستاره‌های کوچک برای کامنت‌ها const renderSmallStars = (rate: number) => { return ( diff --git a/src/app/(projects)/projects/specialists/page.tsx b/src/app/(projects)/projects/specialists/page.tsx index c7b388d..12eacb9 100644 --- a/src/app/(projects)/projects/specialists/page.tsx +++ b/src/app/(projects)/projects/specialists/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -17,10 +17,16 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel"; import { Post } from "@/types/types"; +import { getPostMedia } from "@/lib/explore/postMedia"; import { validateMinMaxPairs } from "@/lib/rangeValidation"; import toast from "react-hot-toast"; const SWIPE_THRESHOLD = 72; +// عین PAGE_LIMIT/PREFETCH_THRESHOLD توی SpecialistsScreen.tsx اندروید — هر صفحه +// PAGE_LIMIT «پست» می‌گیره (نه کاربر یکتا)، و وقتی به PREFETCH_THRESHOLD کارت مونده +// به آخرِ لیستِ لودشده برسیم، صفحه‌ی بعد از پیش لود می‌شه +const PAGE_LIMIT = 30; +const PREFETCH_THRESHOLD = 5; const slideVariants = { enter: (direction: number) => ({ @@ -58,8 +64,12 @@ function SpecialistCard({ viewProfileLabel: string; }) { const displayName = getDisplayName(user, userFallback); - const previewPath = user.previewPost?.files?.[0]?.path; - const previewUrl = previewPath ? buildStorageUrl(previewPath) : null; + // بدون فال‌بک به post_images/post_video، پستِ آخرِ بعضی کاربرها که فیلد files + // خالی داشتن اصلاً پیش‌نمایشی نداشت (کارت کاملاً خالی می‌موند) — عین getPostMedia + // که خودِ فید اصلی برای تشخیص عکس/ویدئو استفاده می‌کنه + const media = user.previewPost + ? getPostMedia(user.previewPost as Post) + : null; const profileUrl = user.profile_image ? buildStorageUrl(user.profile_image) : null; @@ -68,10 +78,11 @@ function SpecialistCard({ return (
- {previewUrl ? ( - user.previewPost?.type === "video" ? ( + {media ? ( + media.kind === "video" ? (
) : null} + + {collabUser && + (isModelExpertise(collabUser.expertise) ? ( + setCollabUser(null)} + userId={collabUser._id} + hairColor={collabUser.hair_color} + eyeColor={collabUser.eye_color} + height={collabUser.height} + size={collabUser.size} + weight={collabUser.weight} + isMain + /> + ) : ( + setCollabUser(null)} + userId={collabUser._id} + services={collabUser.services} + /> + ))} ); } diff --git a/src/app/settings/edit/birthday/page.tsx b/src/app/settings/edit/birthday/page.tsx new file mode 100644 index 0000000..f8045da --- /dev/null +++ b/src/app/settings/edit/birthday/page.tsx @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; +import useAxios from "@/hooks/useAxios"; +import { useUser } from "@/hooks/useUser"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import DatePicker from "react-multi-date-picker"; +import persian from "react-date-object/calendars/persian"; +import persian_fa from "react-date-object/locales/persian_fa"; +import Image from "next/image"; +import moment from "jalali-moment"; + +const p2e = (es: string): string => + es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48)); + +// نسخه‌ی مستقل و سبک برای ثبتِ فقط تاریخ تولد، بدون شبا/کد ملیِ الزامیِ +// POST /verify/auth (نگاه کنید settings/edit/shaba/page.tsx) — عیناً همون +// مکانیزمِ انتخابگرِ تاریخِ آن صفحه، ذخیره از طریق PATCH /verify/personal_details +function EditBirthdayPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const user = useUser(); + const [birthDate, setBirthDate] = useState(""); + + useEffect(() => { + if (user?.birthday) { + setBirthDate(moment(user.birthday, "YYYY/MM/DD").format("jYYYY/jMM/jDD")); + } + }, [user?.birthday]); + + const submit = async () => { + if (!birthDate) { + toast.error(t("auth.birthDateRequired")); + return; + } + try { + await request("PATCH", "/verify/personal_details", { + birthday: moment(p2e(birthDate), "jYYYY/jMM/jDD").format("YYYY/MM/DD"), + }); + toast.success(t("settings.edit.personalDetails.saveSuccess")); + router.push("/settings/edit/personal-details"); + } catch (err: any) { + toast.error(err?.response?.data?.message || t("settings.edit.unknownError")); + } + }; + + const canSave = Boolean(birthDate) && !loading; + + return ( + + +
+ + {!user ? ( + + ) : ( +
+
+ + setBirthDate(value?.format("YYYY/MM/DD") || "") + } + calendarPosition="bottom-center" + placeholder={t("auth.birthDatePlaceholder")} + inputClass="w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-gray-300 bg-secondary-light dark:bg-secondary-dark dark:border-neutral-600 font-medium h-[44px]" + /> + +
+ + + {t("settings.edit.save")} + +
+ )} +
+
+
+ ); +} + +export default EditBirthdayPage; diff --git a/src/app/settings/edit/gender/page.tsx b/src/app/settings/edit/gender/page.tsx new file mode 100644 index 0000000..655cd54 --- /dev/null +++ b/src/app/settings/edit/gender/page.tsx @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import Container from "@/components/elements/Container"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import EditPageHeader from "@/components/settings/EditPageHeader"; +import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons"; +import useAxios from "@/hooks/useAxios"; +import { useUser } from "@/hooks/useUser"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import Image from "next/image"; + +// نسخه‌ی مستقل از (auth)/verify/gender/page.tsx: به‌جای پیشروی توی ویزارد ثبت‌نام، +// فقط ذخیره می‌کنه و به «مشخصات فردی» برمی‌گرده — برای تکمیل جنسیتِ ثبت‌نشده از +// همون صفحه (نگاه کنید src/app/settings/edit/personal-details/page.tsx) +function EditGenderPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const user = useUser(); + const [gender, setGender] = useState<"male" | "female" | "">( + user?.gender === "male" || user?.gender === "female" ? user.gender : "" + ); + + const submit = async () => { + if (!gender) { + toast.error(t("auth.genderRequired")); + return; + } + try { + const mobile = + typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + await request("POST", "/verify/gender", { mobile, gender }); + toast.success(t("settings.edit.personalDetails.saveSuccess")); + router.push("/settings/edit/personal-details"); + } catch (err: any) { + toast.error(err?.response?.data?.message || t("auth.unknownError")); + } + }; + + return ( + + +
+ + {!user ? ( + + ) : ( +
+
+ setGender("female")} + className={`w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${ + gender === "female" ? "btn-modern--selected text-white" : "" + }`} + > + gender icon + {t("auth.female")} + + setGender("male")} + className={`w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${ + gender === "male" ? "!bg-[#3E8BFF] !text-white" : "" + }`} + > + gender icon + {t("auth.male")} + +
+ + + {t("settings.edit.save")} + +
+ )} +
+
+
+ ); +} + +export default EditGenderPage; diff --git a/src/app/settings/edit/link-device/page.tsx b/src/app/settings/edit/link-device/page.tsx index 9aabd6d..00895d1 100644 --- a/src/app/settings/edit/link-device/page.tsx +++ b/src/app/settings/edit/link-device/page.tsx @@ -24,7 +24,6 @@ export default function LinkDevicePage() { const { t } = useTranslation("common"); const router = useRouter(); const [phase, setPhase] = useState("loading"); - const [linkId, setLinkId] = useState(null); const [approveUrl, setApproveUrl] = useState(null); const ephemeralPrivateKeyRef = useRef(null); @@ -44,7 +43,6 @@ export default function LinkDevicePage() { try { const { linkId: id, ephemeralPrivateKey } = await startDeviceLinkSession(); ephemeralPrivateKeyRef.current = ephemeralPrivateKey; - setLinkId(id); const url = `${window.location.origin}/settings/edit/link-device/approve?id=${id}`; setApproveUrl(url); diff --git a/src/app/settings/edit/personal-details/page.tsx b/src/app/settings/edit/personal-details/page.tsx index fcefe19..4835391 100644 --- a/src/app/settings/edit/personal-details/page.tsx +++ b/src/app/settings/edit/personal-details/page.tsx @@ -13,12 +13,96 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import RoundedSelect from "@/components/ui/RoundedSelect"; +import MultiSelectDropdown from "@/components/ui/MultiSelectDropdown"; +import Link from "next/link"; +import { + buildRelationshipGoalOptions, + buildMaritalStatusNewOptions, + buildIncomeRangeOptions, + buildPersonalityTraitsOptions, + buildLifestyleOptions, + buildInterestsOptions, + buildValuesOptions, + buildRelationshipViewOptions, + buildGeoPreferenceOptions, + buildChildrenStatusOptions, + buildSmokingStatusOptions, + RELATIONSHIP_GOAL_MAX, + PERSONALITY_TRAITS_MAX, + LIFESTYLE_MAX, + INTERESTS_MAX, + VALUES_MAX, + type RelationshipView, + type GeoPreference, + type ChildrenStatus, + type SmokingStatus, +} from "@/lib/personalDetailsFieldOptions"; -type MaritalStatus = "single" | "divorced" | null; +type MaritalStatus = + | "single" + | "divorced" + | "widowed" + | "not_in_relationship" + | "prefer_not_to_say" + | null; type ResidenceType = "owner" | "tenant" | null; type VehicleType = "none" | "car" | "motorcycle" | null; type PersonalStyle = "formal" | "sporty" | null; -type IncomeRange = "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null; +type IncomeRange = + | "under_40m" + | "40m_50m" + | "50m_60m" + | "60m_70m" + | "70m_80m" + | "80m_90m" + | "90m_100m" + | "100m_150m" + | "150m_200m" + | "over_200m" + | null; + +// عین ageFromBirthday توی settings/chats/find-partner/page.tsx — برای نمایشِ +// فقط‌خواندنیِ سنِ واقعیِ کاربر (نه ترجیح جستجو) توی همین صفحه +function ageFromBirthday(birthday?: string | null): number | null { + if (!birthday) return null; + const diffMs = Date.now() - new Date(birthday).getTime(); + if (Number.isNaN(diffMs)) return null; + return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000)); +} + +// ردیف فقط‌خواندنی برای جنسیت/سنِ واقعیِ کاربر (از پروفایل، نه ترجیح جستجو) — +// طبق درخواست کاربر seeking_gender/desired_age_range فقط باید توی فیلتر «یافتن +// همکار» (FindPartnerFilterModal.tsx) بمونن، نه توی ویرایش مشخصات فردی؛ اگه +// مقدار واقعی هنوز ثبت نشده، به‌جاش یه لینک «افزودن» نشون داده می‌شه +function ReadOnlyValueRow({ + label, + value, + href, +}: { + label: string; + value: string | null; + href: string; +}) { + return ( +
+

{label}

+ {value ? ( +
+ {value} +
+ ) : ( + + افزودن + + + + )} +
+ ); +} function PersonalDetailsPage() { const { t } = useTranslation("common"); @@ -36,6 +120,30 @@ function PersonalDetailsPage() { const [incomeRange, setIncomeRange] = useState(null); const [moralTraits, setMoralTraits] = useState(""); + const [relationshipGoal, setRelationshipGoal] = useState([]); + const [personalityTraits, setPersonalityTraits] = useState([]); + const [lifestyle, setLifestyle] = useState([]); + const [interests, setInterests] = useState([]); + const [values, setValues] = useState([]); + const [relationshipView, setRelationshipView] = useState(""); + const [geoPreference, setGeoPreference] = useState(""); + const [childrenStatus, setChildrenStatus] = useState(""); + const [smokingStatus, setSmokingStatus] = useState(""); + + const maritalStatusNewOptions = buildMaritalStatusNewOptions(t); + const incomeRangeOptions = buildIncomeRangeOptions(t); + const relationshipGoalOptions = buildRelationshipGoalOptions(t); + const personalityTraitsOptions = buildPersonalityTraitsOptions(t); + const lifestyleOptions = buildLifestyleOptions(t); + const interestsOptions = buildInterestsOptions(t); + const valuesOptions = buildValuesOptions(t); + const relationshipViewOptions = buildRelationshipViewOptions(t); + const geoPreferenceOptions = buildGeoPreferenceOptions(t); + const childrenStatusOptions = buildChildrenStatusOptions(t); + const smokingStatusOptions = buildSmokingStatusOptions(t); + const selectedCountLabel = (count: number) => + t("settings.edit.personalDetails.multiSelectSelectedCount", { count }); + useEffect(() => { if (!user) return; setMaritalStatus(user.marital_status ?? null); @@ -47,6 +155,15 @@ function PersonalDetailsPage() { setJob(user.job ?? ""); setIncomeRange(user.income_range ?? null); setMoralTraits(user.moral_traits ?? ""); + setRelationshipGoal(user.relationship_goal ?? []); + setPersonalityTraits(user.personality_traits ?? []); + setLifestyle(user.lifestyle ?? []); + setInterests(user.interests ?? []); + setValues(user.values ?? []); + setRelationshipView(user.relationship_view ?? ""); + setGeoPreference(user.geo_preference ?? ""); + setChildrenStatus(user.children_status ?? ""); + setSmokingStatus(user.smoking_status ?? ""); }, [user]); const handleSave = async () => { @@ -61,6 +178,15 @@ function PersonalDetailsPage() { job: job || null, income_range: incomeRange, moral_traits: moralTraits || null, + relationship_goal: relationshipGoal.length ? relationshipGoal : null, + personality_traits: personalityTraits.length ? personalityTraits : null, + lifestyle: lifestyle.length ? lifestyle : null, + interests: interests.length ? interests : null, + values: values.length ? values : null, + relationship_view: relationshipView || null, + geo_preference: geoPreference || null, + children_status: childrenStatus || null, + smoking_status: smokingStatus || null, }); toast.success(t("settings.edit.personalDetails.saveSuccess")); router.push("/settings/edit"); @@ -90,21 +216,31 @@ function PersonalDetailsPage() {

{t("settings.edit.personalDetails.maritalStatus")}

-
+
+ {maritalStatusNewOptions.map((opt) => ( + + ))}
@@ -220,41 +356,14 @@ function PersonalDetailsPage() { {/* درآمد */} -
-

- {t("settings.edit.personalDetails.incomeRange")} -

-
- - - - -
-
+ setIncomeRange((v || null) as IncomeRange)} + className="mx-auto" + /> {/* خصوصیات اخلاقی */}
@@ -270,6 +379,149 @@ function PersonalDetailsPage() { className="w-full rounded-3xl border border-neutral-950 bg-white px-4 py-3 font-medium text-neutral-900 dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50" />
+ +
+ + {/* هدف از رابطه */} + + + {/* جنسیت (فقط‌خواندنی، از ثبت‌نام) */} + + + {/* سن (فقط‌خواندنی، از تاریخ تولد ثبت‌شده) */} + + + {/* ویژگی‌های شخصیتی */} + + + {/* سبک زندگی */} + + + {/* علاقه‌مندی‌ها */} + + + {/* ارزش‌ها */} + + + {/* دیدگاه نسبت به رابطه */} + setRelationshipView(v as RelationshipView)} + className="mx-auto" + /> + + {/* محدوده جغرافیایی مورد نظر */} + setGeoPreference(v as GeoPreference)} + className="mx-auto" + /> + + {/* وضعیت فرزند */} + setChildrenStatus(v as ChildrenStatus)} + className="mx-auto" + /> + + {/* وضعیت مصرف دخانیات */} + setSmokingStatus(v as SmokingStatus)} + className="mx-auto" + />
([]); const [coursesPage, setCoursesPage] = useState(1); diff --git a/src/components/academy/MainModelCard.tsx b/src/components/academy/MainModelCard.tsx index a945580..82fc94b 100644 --- a/src/components/academy/MainModelCard.tsx +++ b/src/components/academy/MainModelCard.tsx @@ -27,7 +27,6 @@ import "slick-carousel/slick/slick-theme.css"; import useAxios from "@/hooks/useAxios"; import { useRouter } from "next/navigation"; import { navigateWithSavedScroll } from "@/lib/listScrollRestoration"; -import { forEach } from "lodash"; import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -58,23 +57,18 @@ function MainModelCard({ user_id, caption, type, - price, cuorse_name, category, - offer, academyId, number_of_course_content, course_image, } = postData; // console.log(postData._id); - + const user = useUserById(user_id); - const profile_image = user?.profile_image; const verify_badge = user?.verify_badge; - const user_score = user?.user_score; - const rate = user?.rate; const { request } = useAxios(); const router = useRouter() diff --git a/src/components/academy/MainModelCardActions.tsx b/src/components/academy/MainModelCardActions.tsx index f77ad13..bbf5a5d 100644 --- a/src/components/academy/MainModelCardActions.tsx +++ b/src/components/academy/MainModelCardActions.tsx @@ -1,6 +1,6 @@ "use client"; -import { Course, Post } from "@/types/types"; +import { Course } from "@/types/types"; import Image from "next/image"; import React, { useEffect, useState } from "react"; import ReportModal from "../main/ReportModal"; @@ -10,8 +10,6 @@ import toast from "react-hot-toast"; import ModelDetailModal from "../models/ModelDetailModal"; import ModelServiceModal from "../models/ModelServiceModal"; import { useUserById } from "@/hooks/getUserById"; -import { AxiosError } from "axios"; -import useInfiniteScroll from "@/hooks/useInfiniteScroll"; import { useRouter } from "next/navigation"; import { useRequireAuth } from "@/lib/auth/useRequireAuth"; import { SocialIconButton, SocialStatButton } from "@/components/ui/SocialStatButton"; @@ -67,7 +65,7 @@ function MainModelCardActions({ postData }: { postData: Course }) { if (liked === false) { try { - const response = await request("POST", `/academy/academy/course/like`, { + await request("POST", `/academy/academy/course/like`, { courseId: _id, }); @@ -79,7 +77,7 @@ function MainModelCardActions({ postData }: { postData: Course }) { } if (liked === true) { try { - const response = await request( + await request( "POST", `/academy/academy/course/dislike`, { diff --git a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx index 0f21644..b1e7a29 100644 --- a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx +++ b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx @@ -5,7 +5,7 @@ import { Swiper, SwiperSlide } from "swiper/react"; import "swiper/css"; import { Pagination } from "swiper/modules"; import Image from "next/image"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; interface BillboardImageSliderProps { images: string[]; diff --git a/src/components/billboards/Profile/AdProfileContentPosts.tsx b/src/components/billboards/Profile/AdProfileContentPosts.tsx index 6e83d22..57b2c7d 100644 --- a/src/components/billboards/Profile/AdProfileContentPosts.tsx +++ b/src/components/billboards/Profile/AdProfileContentPosts.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; import React, { useState } from "react"; import Modal from "react-modal"; import { Swiper, SwiperSlide } from "swiper/react"; diff --git a/src/components/chat/E2eeIdentityGate.tsx b/src/components/chat/E2eeIdentityGate.tsx index 2e34aee..e37dd25 100644 --- a/src/components/chat/E2eeIdentityGate.tsx +++ b/src/components/chat/E2eeIdentityGate.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Modal from "@/components/elements/Modal"; import RoundedButton from "@/components/elements/RoundedButton"; @@ -8,24 +8,21 @@ import { IdentityRecoveryNeededError, createFreshIdentity, ensureIdentityKeys, - fetchMyKeyBackup, recoverIdentityFromBackup, - saveKeyBackup, } from "@/lib/e2ee"; -const SETUP_DISMISS_KEY = "e2ee_backup_setup_dismissed"; - -type GateState = - | { kind: "idle" } - | { kind: "recovery"; hasBackup: boolean } - | { kind: "setupPrompt" }; +type GateState = { kind: "idle" } | { kind: "recovery"; hasBackup: boolean }; /** * توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount می‌شه — جایی که - * ensureIdentityKeys برای اولین‌بار صدا زده می‌شه. اگه یه هویتِ قبلی روی سرور باشه - * که این دستگاه بهش دسترسی نداره، به‌جای اینکه کدِ صداکننده (ChatSocketProvider) بی‌صدا - * fail کنه، این کامپوننت رو مسدود می‌کنه تا کاربر یا با PIN بازیابی کنه یا صراحتاً - * بپذیره هویتِ تازه بسازه (پیام‌های قدیمی برای همیشه قفل می‌مونن) + * ensureIdentityKeys برای اولین‌بار صدا زده می‌شه. + * + * بک‌آپِ کلید حالا کاملاً خودکاره (با رمزِ per-account که سرور خودش می‌سازه، نه PIN + * کاربر) — پس دیگه هیچ پیشنهادِ «رمز بازیابی بساز»ی لازم نیست، و لاگین دوباره/دستگاهِ + * جدید هم خودش را قبل از رسیدن به اینجا با همون رمزِ خودکار بازیابی می‌کنه + * (lib/e2ee/keys.ts: tryAutoRecoverIdentity). این کارت فقط برای حالتِ نادری ظاهر + * می‌شه که حتی اون بازیابیِ خودکار هم شکست بخوره (مثلاً بک‌آپِ سرور با یه PIN دستیِ + * قدیمی — ازقبل‌ازاین‌تغییر — رمزنگاری شده، یا واقعاً هیچ بک‌آپی نیست) */ export default function E2eeIdentityGate({ userId }: { userId: string }) { const { t } = useTranslation("common"); @@ -35,29 +32,39 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) { const [busy, setBusy] = useState(false); const [confirmFresh, setConfirmFresh] = useState(false); - useEffect(() => { - if (!userId) return; - let cancelled = false; - - ensureIdentityKeys(userId) - .then(async () => { - if (cancelled) return; - const dismissed = localStorage.getItem(SETUP_DISMISS_KEY) === "1"; - if (dismissed) return; - const backup = await fetchMyKeyBackup(); - if (!cancelled && !backup) setState({ kind: "setupPrompt" }); - }) - .catch((err) => { - if (cancelled) return; + const runEnsure = useCallback( + async (cancelledRef?: { current: boolean }) => { + try { + await ensureIdentityKeys(userId); + if (!cancelledRef?.current) setState({ kind: "idle" }); + } catch (err) { + if (cancelledRef?.current) return; if (err instanceof IdentityRecoveryNeededError) { setState({ kind: "recovery", hasBackup: err.hasBackup }); } - }); + } + }, + [userId] + ); + useEffect(() => { + if (!userId) return; + const cancelledRef = { current: false }; + void runEnsure(cancelledRef); return () => { - cancelled = true; + cancelledRef.current = true; }; - }, [userId]); + }, [userId, runEnsure]); + + const handleRetryAuto = async () => { + setError(null); + setBusy(true); + try { + await runEnsure(); + } finally { + setBusy(false); + } + }; const handleRecover = async () => { setError(null); @@ -77,37 +84,13 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) { setBusy(true); try { await createFreshIdentity(userId); - setState({ kind: "setupPrompt" }); + setState({ kind: "idle" }); setConfirmFresh(false); } finally { setBusy(false); } }; - const handleSaveBackup = async () => { - if (pin.trim().length < 4) { - setError(t("chats.e2ee.pinTooShort")); - return; - } - setError(null); - setBusy(true); - try { - await saveKeyBackup(userId, pin); - localStorage.setItem(SETUP_DISMISS_KEY, "1"); - setState({ kind: "idle" }); - setPin(""); - } catch { - setError(t("chats.e2ee.backupSaveFailed")); - } finally { - setBusy(false); - } - }; - - const dismissSetupPrompt = () => { - localStorage.setItem(SETUP_DISMISS_KEY, "1"); - setState({ kind: "idle" }); - }; - if (state.kind === "recovery") { return ( {}} height="fit" elevated> @@ -119,6 +102,15 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) { : t("chats.e2ee.recoveryNoBackupDesc")}

+ void handleRetryAuto()} + > + {busy ? "…" : t("chats.e2ee.retryButton")} + + {state.hasBackup ? ( <> - {error &&

{error}

} void handleRecover()} @@ -141,6 +131,8 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) { ) : null} + {error &&

{error}

} + {confirmFresh ? (

@@ -178,51 +170,5 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) { ); } - if (state.kind === "setupPrompt") { - // برخلافِ حالتِ recovery (که واقعاً باید کاربر رو مسدود کنه)، این یه پیشنهادِ - // اختیاریه — نباید با یه بک‌دراپِ تمام‌صفحه بقیه‌ی صفحه (مثلاً دکمه‌ی برگشتِ - // هدر) رو زیرِ خودش قایم کنه. قبلاً از Modal (بک‌دراپِ fixed inset-0) استفاده - // می‌کرد — لمسِ هر جای صفحه، حتی دکمه‌ی برگشت، به‌جای رسیدن به عنصرِ زیرش، - // بک‌دراپ رو می‌گرفت و می‌بست؛ کاربر با «برگشت نمی‌کنه» تجربه‌ش می‌کرد - return ( -

-
-

{t("chats.e2ee.setupTitle")}

-

- {t("chats.e2ee.setupDesc")} -

- setPin(e.target.value)} - placeholder={t("chats.e2ee.pinPlaceholder")} - className="w-full rounded-xl border border-neutral-300 bg-transparent px-4 py-2.5 text-center text-lg tracking-widest outline-none dark:border-neutral-600" - /> - {error &&

{error}

} -
- - {t("chats.e2ee.setupLater")} - - void handleSaveBackup()} - > - {busy ? "…" : t("chats.e2ee.setupSave")} - -
-
-
- ); - } - return null; } diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx index 56b55c4..e843c43 100644 --- a/src/components/chat/ForwardMessageModal.tsx +++ b/src/components/chat/ForwardMessageModal.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import toast from "react-hot-toast"; import IOSSpinner from "@/components/ui/IOSSpinner"; diff --git a/src/components/chats/FindPartnerFilterModal.tsx b/src/components/chats/FindPartnerFilterModal.tsx index 01d62b6..6c8df26 100644 --- a/src/components/chats/FindPartnerFilterModal.tsx +++ b/src/components/chats/FindPartnerFilterModal.tsx @@ -6,8 +6,30 @@ import RoundedInput from "../elements/RoundedInput"; import RoundedButton from "../elements/RoundedButton"; import AppearanceColorSelect from "../models/AppearanceColorSelect"; import CountryProvinceCitySelect, { GeoSelection } from "../ui/CountryProvinceCitySelect"; +import RoundedSelect from "../ui/RoundedSelect"; +import MultiSelectDropdown from "../ui/MultiSelectDropdown"; import { EYE_COLOR_OPTIONS, HAIR_COLOR_OPTIONS } from "@/lib/appearanceOptions"; import { toggleBtnClass } from "@/lib/ui/buttonStyles"; +import { + buildRelationshipGoalOptions, + buildMaritalStatusNewOptions, + buildSeekingGenderOptions, + buildDesiredAgeRangeOptions, + buildPersonalityTraitsOptions, + buildLifestyleOptions, + buildInterestsOptions, + buildValuesOptions, + buildRelationshipViewOptions, + buildGeoPreferenceOptions, + buildChildrenStatusOptions, + buildSmokingStatusOptions, + buildIncomeRangeOptions, + RELATIONSHIP_GOAL_MAX, + PERSONALITY_TRAITS_MAX, + LIFESTYLE_MAX, + INTERESTS_MAX, + VALUES_MAX, +} from "@/lib/personalDetailsFieldOptions"; export type PartnerFilters = { gender: string; @@ -28,6 +50,17 @@ export type PartnerFilters = { job: string; incomeRange: string; geo: GeoSelection; + relationshipGoal: string[]; + seekingGender: string; + desiredAgeRange: string; + personalityTraits: string[]; + lifestyle: string[]; + interests: string[]; + values: string[]; + relationshipView: string; + geoPreference: string; + childrenStatus: string; + smokingStatus: string; }; export const EMPTY_PARTNER_FILTERS: PartnerFilters = { @@ -49,6 +82,17 @@ export const EMPTY_PARTNER_FILTERS: PartnerFilters = { job: "", incomeRange: "", geo: { countryId: null, provinceId: null, cityId: null }, + relationshipGoal: [], + seekingGender: "", + desiredAgeRange: "", + personalityTraits: [], + lifestyle: [], + interests: [], + values: [], + relationshipView: "", + geoPreference: "", + childrenStatus: "", + smokingStatus: "", }; function RangeField({ @@ -145,6 +189,22 @@ export default function FindPartnerFilterModal({ const set = (key: K, value: PartnerFilters[K]) => onChange({ ...filters, [key]: value }); + const maritalStatusNewOptions = buildMaritalStatusNewOptions(t); + const relationshipGoalOptions = buildRelationshipGoalOptions(t); + const seekingGenderOptions = buildSeekingGenderOptions(t); + const desiredAgeRangeOptions = buildDesiredAgeRangeOptions(t); + const personalityTraitsOptions = buildPersonalityTraitsOptions(t); + const lifestyleOptions = buildLifestyleOptions(t); + const interestsOptions = buildInterestsOptions(t); + const valuesOptions = buildValuesOptions(t); + const relationshipViewOptions = buildRelationshipViewOptions(t); + const geoPreferenceOptions = buildGeoPreferenceOptions(t); + const childrenStatusOptions = buildChildrenStatusOptions(t); + const smokingStatusOptions = buildSmokingStatusOptions(t); + const incomeRangeOptions = buildIncomeRangeOptions(t); + const selectedCountLabel = (count: number) => + t("settings.edit.personalDetails.multiSelectSelectedCount", { count }); + return ( @@ -278,16 +339,128 @@ export default function FindPartnerFilterModal({ />
- set("incomeRange", v)} - options={[ - { value: "under_5m", label: t("settings.edit.personalDetails.incomeUnder5m") }, - { value: "5m_10m", label: t("settings.edit.personalDetails.income5m10m") }, - { value: "10m_20m", label: t("settings.edit.personalDetails.income10m20m") }, - { value: "over_20m", label: t("settings.edit.personalDetails.incomeOver20m") }, - ]} + className="mx-auto" + /> + +
+ set("relationshipGoal", v)} + max={RELATIONSHIP_GOAL_MAX} + maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", { + max: RELATIONSHIP_GOAL_MAX, + })} + formatSelectedSummary={selectedCountLabel} + /> +
+ + set("seekingGender", v)} + /> + + set("desiredAgeRange", v)} + /> + + set("personalityTraits", v)} + max={PERSONALITY_TRAITS_MAX} + maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", { + max: PERSONALITY_TRAITS_MAX, + })} + formatSelectedSummary={selectedCountLabel} + /> + + set("lifestyle", v)} + max={LIFESTYLE_MAX} + maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", { + max: LIFESTYLE_MAX, + })} + formatSelectedSummary={selectedCountLabel} + /> + + set("interests", v)} + max={INTERESTS_MAX} + maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", { + max: INTERESTS_MAX, + })} + formatSelectedSummary={selectedCountLabel} + /> + + set("values", v)} + max={VALUES_MAX} + maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", { + max: VALUES_MAX, + })} + formatSelectedSummary={selectedCountLabel} + /> + + set("relationshipView", v)} + /> + + set("geoPreference", v)} + /> + + set("childrenStatus", v)} + /> + + set("smokingStatus", v)} />
diff --git a/src/components/course/CourseForm.tsx b/src/components/course/CourseForm.tsx index 4b47b55..3925814 100644 --- a/src/components/course/CourseForm.tsx +++ b/src/components/course/CourseForm.tsx @@ -1,13 +1,11 @@ "use client"; -import { useState, useRef, useEffect, useMemo } from "react"; -import { useForm, useFieldArray } from "react-hook-form"; +import { useState, useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { motion } from "framer-motion"; import { toast } from "react-hot-toast"; -import { ImageIcon } from "lucide-react"; -import { Image } from "lucide-react"; import { Form, @@ -30,9 +28,9 @@ import { import { Checkbox } from "@/components/ui/checkbox"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Trash2, Video, Plus, Upload, FileVideo, Play } from "lucide-react"; +import { Trash2, Video, Plus, FileVideo, Play } from "lucide-react"; import useAxios from "@/hooks/useAxios"; -import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl"; +import { buildStorageUrl } from "../main/BaseUrl"; import { useTranslation } from "react-i18next"; import { useAppLanguage } from "@/contexts/LanguageProvider"; @@ -110,10 +108,6 @@ export function CourseForm({ const [previewImage, setPreviewImage] = useState(""); const [previewProfile, setPreviewProfile] = useState(""); - const [avatar, setAvatar] = useState(null); - // State برای عکس پکیج - const [packageImageFile, setPackageImageFile] = useState(null); - const [packageImagePreview, setPackageImagePreview] = useState(""); // State برای عکس‌ها const [imageFields, setImageFields] = useState([]); @@ -130,9 +124,6 @@ export function CourseForm({ const [videoFile, setVideoFile] = useState(null); const [videoPreview, setVideoPreview] = useState(""); const [isFreeVideo, setIsFreeVideo] = useState(false); - const [videoPreviewUrls, setVideoPreviewUrls] = useState<{ - [key: number]: string; - }>({}); // ========================================== // تغییر: استفاده از دسته‌بندی آکادمی به جای تبلیغات @@ -144,7 +135,7 @@ export function CourseForm({ const [uploadProgress, setUploadProgress] = useState(0); const [isUploading, setIsUploading] = useState(false); - const { request, loading, error } = useAxios(); + const { request } = useAxios(); // ========================================== // دریافت دسته‌بندی‌های آکادمی از API جدید @@ -319,15 +310,6 @@ export function CourseForm({ } }, [initialData, request, form]); - const { - // fields: videoFields, - append: appendVideo, - remove: removeVideo, - } = useFieldArray({ - control: form.control, - name: "videos", - }); - // تابع برای انتخاب فایل ویدیو const handleVideoFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -350,17 +332,7 @@ export function CourseForm({ } }; - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - }; - // اضافه کردن state های جدید - const [uploadChunks, setUploadChunks] = useState([]); const [currentChunk, setCurrentChunk] = useState(0); const [totalChunks, setTotalChunks] = useState(0); @@ -600,26 +572,9 @@ export function CourseForm({ } }; - const fileToBase64WithProgress = (file, onProgress) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onprogress = (event) => { - if (event.lengthComputable && onProgress) { - const percentComplete = Math.round((event.loaded / event.total) * 50); // 50% برای تبدیل - onProgress(percentComplete); - } - }; - - reader.onload = () => resolve(reader.result); - reader.onerror = (error) => reject(error); - reader.readAsDataURL(file); - }); - }; - const handleRemoveVideo = async (index: number, field) => { console.log(field.courseId); - const deleteCourseContent = await request( + await request( "DELETE", `/academy/academy/course/content/video`, { @@ -641,7 +596,6 @@ export function CourseForm({ form.setValue("packageImage", url); } setPreviewProfile(previewUrl); - setAvatar(previewUrl); } }; diff --git a/src/components/course/CourseManager.tsx b/src/components/course/CourseManager.tsx index f1a284c..554af46 100644 --- a/src/components/course/CourseManager.tsx +++ b/src/components/course/CourseManager.tsx @@ -11,7 +11,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Course } from "@/types/types"; import { toast } from "react-hot-toast"; @@ -37,26 +36,6 @@ function appendCourseFormFields(formData: FormData, data: any) { formData.append("contact_instagram", data.contact_instagram || ""); } -// دیتای موقت برای نمایش -const mockCourses: Course[] = [ - { - id: "1", - name: "دوره جامع React", - price: 1200000, - category: "programming", - discount: 20, - caption: "آموزش کامل React از مقدماتی تا پیشرفته", - duration: "15 ساعت", - videoCount: 25, - instructor: "علی رضایی", - packageImage: "/courses/react.jpg", - videos: [ - { title: "مقدمه", url: "video1.mp4", isFree: true }, - { title: "نصب و راه اندازی", url: "video2.mp4", isFree: false }, - ], - }, -]; - export function CourseManager() { const { t } = useTranslation("common"); const subscriptionPlans = useMemo( @@ -98,7 +77,7 @@ export function CourseManager() { const [totalCourses, setTotalCourses] = useState(0); const itemsPerPage = 6; - const { request, loading, error } = useAxios(); + const { request } = useAxios(); // تابع دریافت دوره‌ها با صفحه‌بندی const fetchCourses = async (page = 1) => { @@ -149,12 +128,6 @@ export function CourseManager() { try { // اینجا درخواست به API بزن - const newCourse = { - ...data, - id: Date.now().toString(), - createdAt: new Date(), - }; - const fileInput = document.getElementById( "fileInput" ) as HTMLInputElement; @@ -223,7 +196,7 @@ export function CourseManager() { } finally { setIsLoading(false); } - } catch (error) { + } catch { toast.error(t("academyCourse.createError")); } finally { setIsLoading(false); @@ -251,7 +224,7 @@ export function CourseManager() { } // اگر عکس جدیدی انتخاب نشده، فیلد course_image رو ارسال نکن - const response = await request( + await request( "PATCH", "/academy/academy/update/course", formData, diff --git a/src/components/course/CustomDialog.tsx b/src/components/course/CustomDialog.tsx index e3624b1..77b0664 100644 --- a/src/components/course/CustomDialog.tsx +++ b/src/components/course/CustomDialog.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import i18n from "@/lib/i18n"; +import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; @@ -16,7 +17,10 @@ const DialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -30,7 +34,10 @@ const DialogContent = React.forwardRef< {children} @@ -48,7 +55,7 @@ const DialogHeader = ({ ...props }: React.HTMLAttributes) => (
); @@ -59,7 +66,10 @@ const DialogFooter = ({ ...props }: React.HTMLAttributes) => (
); @@ -71,7 +81,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -83,7 +93,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/main/UserInfo.tsx b/src/components/main/UserInfo.tsx index a94685e..57cb160 100644 --- a/src/components/main/UserInfo.tsx +++ b/src/components/main/UserInfo.tsx @@ -1,4 +1,3 @@ -import Image from "next/image"; import React from "react"; import Link from "next/link"; import ProfileAvatar from "./ProfileAvatar"; diff --git a/src/components/models/MainModelCard/MainModelCard.tsx b/src/components/models/MainModelCard/MainModelCard.tsx index c2571b8..3f64c60 100644 --- a/src/components/models/MainModelCard/MainModelCard.tsx +++ b/src/components/models/MainModelCard/MainModelCard.tsx @@ -11,7 +11,7 @@ import { Post } from "@/types/types"; import Image from "next/image"; import MainModelCardActions from "./MainModelCardActions"; import Link from "next/link"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; import VerificationBadge from "@/components/main/VerificationBadge"; import ScoreRateStats from "@/components/main/ScoreRateStats"; import { useUserById } from "@/hooks/getUserById"; @@ -27,7 +27,6 @@ import { reorderCaptionLinksToEnd, splitCaptionBodyAndLinks, } from "@/lib/postLinkCaption"; -import { cn } from "@/lib/utils"; import { getPostSubExpertiseLabels } from "@/lib/subExpertiseDisplay"; import { localizedGeoName } from "@/utils/geoName"; import { useTranslation } from "react-i18next"; @@ -233,19 +232,23 @@ function MainModelCard({
-
+
+
+ {user_level || t("models.newcomer")} +
+ {city?.name ? {localizedGeoName(city)} : null} + {expertise ? {expertise} : null} + {displaySubExpertise ? {displaySubExpertise} : null} +
+
+
+

{user_name}

- -
-
-
- {first_name} {last_name} -
{subExpertise && matchedExpertise2.length > 0 && ( @@ -355,15 +358,11 @@ function MainModelCard({
-
-
- {user_level || t("models.newcomer")} -
- {city?.name ? {localizedGeoName(city)} : null} - {expertise ? {expertise} : null} - {displaySubExpertise ? {displaySubExpertise} : null} -
+
+
+ {first_name} {last_name}
+
diff --git a/src/components/models/ModelPage/MainModelCardPost.tsx b/src/components/models/ModelPage/MainModelCardPost.tsx index f162722..6e783b0 100644 --- a/src/components/models/ModelPage/MainModelCardPost.tsx +++ b/src/components/models/ModelPage/MainModelCardPost.tsx @@ -6,7 +6,7 @@ import Image from "next/image"; import MainModelCardActions from "../MainModelCard/MainModelCardActions"; import Link from "next/link"; import CaptionWithMentions from "@/components/posts/CaptionWithMentions"; -import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; import VerificationBadge from "@/components/main/VerificationBadge"; import ScoreRateStats from "@/components/main/ScoreRateStats"; import { useUserById } from "@/hooks/getUserById"; diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 678fdb1..b3407a2 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,28 +1,14 @@ "use client"; -import { - IconCreditCard, - IconDotsVertical, - IconLogout, - IconNotification, - IconUserCircle, -} from "@tabler/icons-react"; - import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from "@/components/ui/sidebar"; interface UserDataItem { @@ -41,8 +27,6 @@ export function NavUser({ data: UserDataItem; }; }) { - const { isMobile } = useSidebar(); - const firstData = user.data; return ( diff --git a/src/components/posts/FollowingSuggestionsCarousel.tsx b/src/components/posts/FollowingSuggestionsCarousel.tsx index 5bac754..5b786d4 100644 --- a/src/components/posts/FollowingSuggestionsCarousel.tsx +++ b/src/components/posts/FollowingSuggestionsCarousel.tsx @@ -12,10 +12,16 @@ export type SuggestedFollowUser = { last_name?: string; expertise?: string; profile_image?: string; + // مدل واقعی بک‌اند مدیا رو با post_images (آرایه)/post_video برمی‌گردونه، نه فقط + // files — بعضی مسیرها (مثلاً /users/web) فیلد files رو خالی می‌فرستن؛ بدون این دو + // فیلد، getPostMedia (lib/explore/postMedia.ts) هیچ مدیایی پیدا نمی‌کرد و کارت + // کاملاً خالی می‌موند previewPost?: { _id: string; type?: string; - files?: { path: string }[]; + files?: { path: string; type?: string }[]; + post_images?: string[]; + post_video?: string | null; caption?: string; }; is_following?: boolean; diff --git a/src/components/posts/ReelsPostCard.tsx b/src/components/posts/ReelsPostCard.tsx index 3b34a0f..19c444c 100644 --- a/src/components/posts/ReelsPostCard.tsx +++ b/src/components/posts/ReelsPostCard.tsx @@ -633,22 +633,24 @@ export default function ReelsPostCard({ ))}
-
+
{first_name} {last_name} - {userRate != null && ( - - - {userRate.toFixed(1)} - - )} - {userScore != null && ( - - - {userScore} - - )} +
+ {userRate != null && ( + + + {userRate.toFixed(1)} + + )} + {userScore != null && ( + + + {userScore} + + )} +
diff --git a/src/components/projects/SpecialistCarousel.tsx b/src/components/projects/SpecialistCarousel.tsx index e867bc3..d0dd41e 100644 --- a/src/components/projects/SpecialistCarousel.tsx +++ b/src/components/projects/SpecialistCarousel.tsx @@ -17,6 +17,8 @@ import { buildStorageUrl } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { cn } from "@/lib/utils"; import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel"; +import { getPostMedia } from "@/lib/explore/postMedia"; +import type { Post } from "@/types/types"; export type SpecialistCarouselItem = SuggestedFollowUser; @@ -68,9 +70,11 @@ function SpecialistCard({ userFallback: string; }) { const displayName = getDisplayName(user, userFallback); - const previewFile = user.previewPost?.files?.[0]?.path; - const previewUrl = previewFile ? buildStorageUrl(previewFile) : null; - const isVideo = user.previewPost?.type === "video"; + // بدون فال‌بک به post_images/post_video، پستِ آخرِ بعضی کاربرها که فیلد files + // خالی داشتن اصلاً پیش‌نمایشی نداشت — عین getPostMedia که خودِ فید اصلی استفاده می‌کنه + const media = user.previewPost + ? getPostMedia(user.previewPost as Post) + : null; const profileUrl = user.profile_image ? buildStorageUrl(user.profile_image) : null; const profileHref = user.user_name ? `/users/${user.user_name}` : "#"; @@ -83,10 +87,11 @@ function SpecialistCard({ isActive ? "scale-100 opacity-100" : "scale-[0.94] opacity-70" )} > - {previewUrl ? ( - isVideo ? ( + {media ? ( + media.kind === "video" ? (