This commit is contained in:
payacom
2026-08-13 21:20:23 +03:30
parent ef2e86861a
commit a92a3ca5e6
45 changed files with 79894 additions and 297 deletions

View File

@@ -0,0 +1,83 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BookingListCard from "@/components/booking/BookingListCard";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type BookingConfigSummary = {
_id: string;
name: string;
services: { title: string }[];
status: "draft" | "active" | "inactive";
};
export default function BookingListPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [configs, setConfigs] = useState<BookingConfigSummary[] | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
request<{ configs: BookingConfigSummary[] }>("GET", "/bookings/mine")
.then((res) => setConfigs(res?.configs || []))
.catch(() => setConfigs([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredConfigs = useMemo(() => {
if (!configs) return [];
const query = search.trim();
if (!query) return configs;
return configs.filter((config) => config.name.includes(query));
}, [configs, search]);
return (
<LocalePageShell>
<Container>
<PageTitle>{t("booking.myBookingListingsTitle")}</PageTitle>
<div className="mx-auto w-full max-w-md">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("booking.searchBookingListingPlaceholder")}
className="w-full rounded-full border border-neutral-200 bg-white py-2.5 px-4 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900"
/>
<div className="mt-4 flex flex-col gap-3">
{configs === null ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : filteredConfigs.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("booking.emptyList")}
</p>
) : (
filteredConfigs.map((config) => (
<BookingListCard key={config._id} config={config} />
))
)}
</div>
<RoundedButton
type="button"
variant="primary"
onClick={() => router.push("/settings/booking/new/name")}
className="mx-auto mt-6 flex h-11 w-full max-w-xs items-center justify-center gap-2"
>
{t("booking.addNew")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -4,140 +4,52 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import IncomingBookingsList from "@/components/booking/IncomingBookingsList";
import { staticIconUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { saveBookingWizardId } from "@/hooks/useBookingWizardId";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type BookingConfigSummary = {
_id: string;
name: string;
services: { title: string }[];
status: "draft" | "active" | "inactive";
};
function BookingSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [configs, setConfigs] = useState<BookingConfigSummary[] | null>(null);
const load = () => {
request<{ configs: BookingConfigSummary[] }>("GET", "/bookings/mine").then(
(res) => setConfigs(res?.configs || [])
);
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openConfig = (config: BookingConfigSummary) => {
saveBookingWizardId(config._id);
router.push("/settings/booking/new/services?edit=1");
};
const toggleStatus = async (
e: React.MouseEvent,
config: BookingConfigSummary
) => {
e.stopPropagation();
const nextStatus = config.status === "active" ? "inactive" : "active";
try {
await request("PATCH", `/bookings/${config._id}/status`, {
status: nextStatus,
});
setConfigs(
(prev) =>
prev?.map((c) =>
c._id === config._id ? { ...c, status: nextStatus } : c
) || null
);
} catch {
toast.error(t("shops.unknownError"));
}
};
const statusColor = (status: string) =>
status === "active"
? "#008D0E"
: status === "inactive"
? "#BA4141"
: "#BFAF19";
const statusLabel = (status: string) =>
status === "active"
? t("booking.statusActive")
: status === "inactive"
? t("booking.statusInactive")
: t("booking.statusDraft");
return (
<LocalePageShell>
<Container>
<div className="flex items-center justify-between">
<PageTitle>{t("settings.nav.booking")}</PageTitle>
<Link
href="/settings/booking/new/name"
aria-label={t("booking.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
<div className="flex shrink-0 items-center justify-end gap-3">
<Link
href="/settings/booking/list"
aria-label={t("booking.myListingsSwitcher")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/archive-book.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
<Link
href="/settings/booking/new/name"
aria-label={t("booking.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
</div>
</div>
<div className="text-xs md:text-sm">
<UserDetails />
<div className="mt-8 flex flex-col gap-3">
{configs === null ? (
<p className="py-8 text-center text-gray-500">
{t("common.loading")}
</p>
) : configs.length === 0 ? (
<p className="py-8 text-center text-gray-500">
{t("booking.emptyList")}
</p>
) : (
configs.map((config) => (
<button
key={config._id}
type="button"
onClick={() => openConfig(config)}
className="flex w-full items-center justify-between rounded-3xl border border-neutral-200 p-4 text-right dark:border-neutral-700"
>
<div>
<p className="font-bold">{config.name}</p>
<p className="mt-1 text-neutral-500">
{t("booking.servicesCount", {
count: config.services?.length || 0,
})}
</p>
</div>
<button
type="button"
onClick={(e) => toggleStatus(e, config)}
disabled={config.status === "draft"}
style={{ color: statusColor(config.status) }}
className="font-semibold"
>
{statusLabel(config.status)}
</button>
</button>
))
)}
</div>
<IncomingBookingsList />
</div>
</Container>
</LocalePageShell>

View File

@@ -4,60 +4,11 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import UserInfo from "@/components/main/UserInfo";
import useAxios from "@/hooks/useAxios";
import { useEffect, useState } from "react";
import IncomingBookingsList from "@/components/booking/IncomingBookingsList";
import { useTranslation } from "react-i18next";
type BookingBuyer = {
_id: string;
first_name?: string;
last_name?: string;
user_name?: string;
profile_image?: string;
user_level?: string;
is_verified?: string;
verify_badge?: string;
};
type ReceivedBooking = {
_id: string;
buyer: BookingBuyer;
serviceTitle: string;
servicePrice: number;
date: string;
startTime: string;
endTime: string;
status: "pending_payment" | "confirmed" | "cancelled";
createdAt: string;
};
function BookingReservationsPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [bookings, setBookings] = useState<ReceivedBooking[] | null>(null);
useEffect(() => {
request<{ bookings: ReceivedBooking[] }>(
"GET",
"/bookings/reservations?role=provider"
).then((res) => setBookings(res?.bookings || []));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const statusColor = (status: ReceivedBooking["status"]) =>
status === "confirmed"
? "#008D0E"
: status === "cancelled"
? "#BA4141"
: "#BFAF19";
const statusLabel = (status: ReceivedBooking["status"]) =>
status === "confirmed"
? t("booking.statusConfirmed")
: status === "cancelled"
? t("booking.statusCancelled")
: t("booking.statusPendingPayment");
return (
<LocalePageShell>
@@ -65,58 +16,7 @@ function BookingReservationsPage() {
<PageTitle>{t("booking.bookingsListTitle")}</PageTitle>
<div className="text-xs md:text-sm">
<UserDetails />
<div className="mt-8 flex flex-col gap-4">
{bookings === null ? (
<p className="py-10 text-center text-gray-500">
{t("common.loading")}
</p>
) : bookings.length === 0 ? (
<p className="py-10 text-center text-gray-500">
{t("booking.bookingsListEmpty")}
</p>
) : (
bookings.map((booking) => (
<div
key={booking._id}
className="mb-4 block w-full rounded-3xl border border-border-primary-light p-4 font-semibold"
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<UserInfo
profile_image={booking.buyer?.profile_image}
user_level={booking.buyer?.user_level}
first_name={booking.buyer?.first_name}
last_name={booking.buyer?.last_name}
user_name={booking.buyer?.user_name}
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]"
style={{ color: statusColor(booking.status) }}
>
<span>{statusLabel(booking.status)}</span>
</div>
</div>
<div className="mt-3 flex flex-col gap-1 border-t border-neutral-200 pt-3 text-neutral-600 dark:border-neutral-700 dark:text-neutral-300">
<div className="flex justify-between">
<span>{booking.serviceTitle}</span>
<span>
{Number(booking.servicePrice).toLocaleString()}{" "}
{t("settings.toman")}
</span>
</div>
<div className="flex justify-between">
<span>{new Date(booking.date).toISOString().slice(0, 10)}</span>
<span>
{booking.startTime} - {booking.endTime}
</span>
</div>
</div>
</div>
))
)}
</div>
<IncomingBookingsList />
</div>
</Container>
</LocalePageShell>

View File

@@ -268,25 +268,12 @@ function LicensePage() {
</small>
<div className="flex">
<div>
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#dbd40b"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#f5f507"
/>
</svg>
</div>
<Image
src="/images/icons/verify-green.svg"
width={80}
height={80}
alt={t("verificationBadge.licenseAlt")}
/>
</div>
<AuthNextButton onClick={upload} className="mt-20" loading={loadingUpload} disabled={loadingUpload}>
{t("settings.edit.license.submitContinue")}

View File

@@ -0,0 +1,19 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SectionAnalyticsWidget from "@/components/settings/SectionAnalyticsWidget";
import { useTranslation } from "react-i18next";
export default function AcademyAnalyticsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.analyticsHub.sections.academy")}</PageTitle>
<SectionAnalyticsWidget endpoint="/academy/analytics" />
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SectionAnalyticsWidget from "@/components/settings/SectionAnalyticsWidget";
import { useTranslation } from "react-i18next";
export default function BillboardsAnalyticsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.analyticsHub.sections.billboards")}</PageTitle>
<SectionAnalyticsWidget endpoint="/advertising/analytics" />
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SectionAnalyticsWidget from "@/components/settings/SectionAnalyticsWidget";
import { useTranslation } from "react-i18next";
export default function BookingAnalyticsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.analyticsHub.sections.booking")}</PageTitle>
<SectionAnalyticsWidget
endpoint="/bookings/analytics"
statusLabelPrefix="settings.edit.analyticsHub.bookingStatus"
/>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BoldIcon from "@/components/ui/BoldIcon";
import Link from "next/link";
import { useTranslation } from "react-i18next";
type Section = {
key: string;
href: string | null;
icon: string;
};
const SECTIONS: Section[] = [
{ key: "posts", href: "/settings/edit/analytics/posts", icon: "gallery" },
{ key: "shop", href: "/settings/shop", icon: "shop" },
{ key: "academy", href: "/settings/edit/analytics/academy", icon: "video-play" },
{ key: "billboards", href: "/settings/edit/analytics/billboards", icon: "gallery" },
{ key: "projects", href: "/settings/edit/analytics/projects", icon: "briefcase" },
{ key: "booking", href: "/settings/edit/analytics/booking", icon: "calendar" },
];
export default function AnalyticsHubPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.nav.analytics")}</PageTitle>
<div className="mx-auto mt-8 flex w-full max-w-md flex-col gap-3">
{SECTIONS.map((section) => {
const content = (
<div
className={`flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-4 text-right dark:border-neutral-700 ${
section.href ? "" : "opacity-50"
}`}
>
<BoldIcon name={section.icon} size={22} tinted />
<span className="flex-1 text-sm font-semibold">
{t(`settings.edit.analyticsHub.sections.${section.key}`)}
</span>
{!section.href && (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-semibold text-neutral-500 dark:bg-neutral-800">
{t("settings.edit.analyticsHub.comingSoon")}
</span>
)}
</div>
);
return section.href ? (
<Link key={section.key} href={section.href}>
{content}
</Link>
) : (
<div key={section.key}>{content}</div>
);
})}
<Link href="/settings/edit/analytics/region">
<div className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-4 text-right dark:border-neutral-700">
<BoldIcon name="global" size={22} tinted />
<span className="flex-1 text-sm font-semibold">
{t("settings.region.title")}
</span>
</div>
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import { useTranslation } from "react-i18next";
type PostAnalyticsResponse = {
post: {
_id: string;
type: string;
files: { path: string; type: "image" | "video" }[];
caption?: string | null;
createdAt: string;
};
stats: {
likesCount: number;
commentsCount: number;
sendsCount: number;
sharesCount: number;
savesCount: number;
followsFromPost: number;
offersCount: number;
viewsCount: number;
totalWatchMs: number;
followerViews: number;
nonFollowerViews: number;
followerPct: number;
nonFollowerPct: number;
};
geography: {
locatedViewsCount: number;
viewsByProvince: { name: string; count: number }[];
viewsByCity: { name: string; count: number }[];
};
};
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-2xl border border-neutral-200 p-3 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{value}</p>
<p className="text-[11px] text-neutral-500">{label}</p>
</div>
);
}
export default function PostAnalyticsDetailPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const params = useParams();
const postId = String(params?.postId || "");
const [data, setData] = useState<PostAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!postId) return;
request<PostAnalyticsResponse>("GET", `/posts/${postId}/analytics`)
.then((res) => setData(res || null))
.catch(() => setData(null))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [postId]);
if (loading) {
return (
<LocalePageShell>
<Container>
<p className="py-16 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
</Container>
</LocalePageShell>
);
}
if (!data) {
return (
<LocalePageShell>
<Container>
<p className="py-16 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.notFound")}
</p>
</Container>
</LocalePageShell>
);
}
const { stats, geography } = data;
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.postsAnalytics.detailTitle")}</PageTitle>
<div className="mx-auto w-full max-w-md">
<div className="mb-4 flex items-center gap-3">
<span className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
{data.post.files?.[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + data.post.files[0].path}
alt=""
className="h-full w-full object-cover"
/>
)}
</span>
<p className="line-clamp-3 flex-1 text-xs text-neutral-500">
{data.post.caption}
</p>
</div>
<div className="grid grid-cols-3 gap-2">
<StatCard label={t("settings.edit.postsAnalytics.views")} value={stats.viewsCount} />
<StatCard label={t("settings.edit.postsAnalytics.likes")} value={stats.likesCount} />
<StatCard label={t("settings.edit.postsAnalytics.comments")} value={stats.commentsCount} />
<StatCard label={t("settings.edit.postsAnalytics.shares")} value={stats.sharesCount} />
<StatCard label={t("settings.edit.postsAnalytics.saves")} value={stats.savesCount} />
<StatCard label={t("settings.edit.postsAnalytics.sends")} value={stats.sendsCount} />
<StatCard label={t("settings.edit.postsAnalytics.newFollows")} value={stats.followsFromPost} />
<StatCard label={t("settings.edit.postsAnalytics.offers")} value={stats.offersCount} />
<StatCard
label={t("settings.edit.postsAnalytics.watchMinutes")}
value={Math.round(stats.totalWatchMs / 60000)}
/>
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-3 text-sm font-bold">
{t("settings.edit.postsAnalytics.audienceTitle")}
</h3>
<div className="flex justify-between text-xs">
<span>
{t("settings.edit.postsAnalytics.followers")}: {stats.followerViews} (
{stats.followerPct}%)
</span>
<span>
{t("settings.edit.postsAnalytics.nonFollowers")}: {stats.nonFollowerViews} (
{stats.nonFollowerPct}%)
</span>
</div>
<div className="mt-2 flex h-2 w-full overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
<div
className="h-full bg-[#0095f6]"
style={{ width: `${stats.followerPct}%` }}
/>
<div
className="h-full bg-[#ff5c00]"
style={{ width: `${stats.nonFollowerPct}%` }}
/>
</div>
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-1 text-sm font-bold">
{t("settings.edit.postsAnalytics.geographyTitle")}
</h3>
<p className="mb-3 text-[11px] text-neutral-400">
{t("settings.edit.postsAnalytics.geographyHint", {
count: geography.locatedViewsCount,
})}
</p>
{geography.viewsByProvince.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.geographyEmpty")}
</p>
) : (
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={geography.viewsByProvince.slice(0, 8)}
layout="vertical"
margin={{ top: 5, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 10 }} />
<YAxis
type="category"
dataKey="name"
width={70}
tick={{ fontSize: 10 }}
/>
<Tooltip />
<Bar dataKey="count" fill="#0095f6" radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type PostSummary = {
_id: string;
type: string;
files: { path: string; type: "image" | "video" }[];
caption?: string | null;
createdAt: string;
likesCount: number;
commentsCount: number;
viewsCount: number;
};
export default function PostsAnalyticsPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [posts, setPosts] = useState<PostSummary[] | null>(null);
const load = useCallback(() => {
request<{ posts: PostSummary[] }>("GET", "/posts/analytics/mine?limit=30")
.then((res) => setPosts(res?.posts || []))
.catch(() => setPosts([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
load();
}, [load]);
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.analyticsHub.sections.posts")}</PageTitle>
<AccountAnalyticsWidget />
<div className="mx-auto w-full max-w-md">
<h3 className="mb-3 text-sm font-bold">
{t("settings.edit.postsAnalytics.listTitle")}
</h3>
{posts === null ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : posts.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.empty")}
</p>
) : (
<div className="flex flex-col gap-2">
{posts.map((post) => (
<Link
key={post._id}
href={`/settings/edit/analytics/posts/${post._id}`}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"
>
<span className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
{post.files?.[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + post.files[0].path}
alt=""
className="h-full w-full object-cover"
/>
)}
</span>
<span className="flex-1 truncate text-xs text-neutral-500">
{post.caption || t("settings.edit.postsAnalytics.noCaption")}
</span>
<span className="flex shrink-0 flex-col items-end gap-0.5 text-[11px] text-neutral-500">
<span>
{t("settings.edit.postsAnalytics.viewsShort", {
count: post.viewsCount,
})}
</span>
<span>
{t("settings.edit.postsAnalytics.likesShort", {
count: post.likesCount,
})}
</span>
</span>
</Link>
))}
</div>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SectionAnalyticsWidget from "@/components/settings/SectionAnalyticsWidget";
import { useTranslation } from "react-i18next";
export default function ProjectsAnalyticsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.analyticsHub.sections.projects")}</PageTitle>
<SectionAnalyticsWidget endpoint="/workroom/analytics" useImageBaseUrl={false} />
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AuthNextButton from "@/components/auth/AuthNextButton";
import CountryProvinceCitySelect, {
type GeoSelection,
findCountry,
} from "@/components/ui/CountryProvinceCitySelect";
import useAxios from "@/hooks/useAxios";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type ProfileResponse = {
user?: {
country?: { id: number } | null;
province?: { id: number } | null;
city?: { id: number } | null;
};
};
export default function RegionPage() {
const { t } = useTranslation("common");
const { request, loading } = useAxios();
const [selection, setSelection] = useState<GeoSelection>({
countryId: null,
provinceId: null,
cityId: null,
});
useEffect(() => {
request<ProfileResponse>("GET", "/profile", null, { noToast: true })
.then((res) => {
const u = res?.user;
if (u) {
setSelection({
countryId: u.country?.id ?? null,
provinceId: u.province?.id ?? null,
cityId: u.city?.id ?? null,
});
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = async () => {
try {
await request("PATCH", "/account/region", {
country_id: selection.countryId,
province_id: selection.provinceId,
city_id: selection.cityId,
});
toast.success(t("settings.region.saveSuccess"));
} catch {
toast.error(t("shops.unknownError"));
}
};
const selectedCountry = findCountry(selection.countryId);
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.region.title")}</PageTitle>
<div className="mx-auto mt-8 w-full max-w-sm">
<p className="mb-4 text-center text-xs text-neutral-500">
{t("settings.region.hint")}
</p>
<CountryProvinceCitySelect value={selection} onChange={setSelection} />
{selectedCountry && (
<p className="mt-3 text-center text-xs text-neutral-400">
{t("settings.region.selectedLabel", {
country: selectedCountry.name_fa,
})}
</p>
)}
<AuthNextButton
type="button"
className="mt-8"
loading={loading}
disabled={loading || !selection.countryId}
onClick={handleSave}
>
{t("settings.edit.save")}
</AuthNextButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { getStoredUserId, isAuthenticated } from "@/lib/auth/session";
import { ensureIdentityKeys } from "@/lib/e2ee/keys";
import {
getDeviceLinkSession,
approveDeviceLink,
} from "@/lib/e2ee/deviceLink";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type Phase = "loading" | "confirm" | "approving" | "done" | "invalid";
export default function ApproveLinkDevicePage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const linkId = searchParams.get("id");
const [phase, setPhase] = useState<Phase>("loading");
const load = useCallback(async () => {
if (!linkId || !isAuthenticated()) {
setPhase("invalid");
return;
}
const session = await getDeviceLinkSession(linkId);
if (!session || session.status !== "pending") {
setPhase("invalid");
return;
}
setPhase("confirm");
}, [linkId]);
useEffect(() => {
void load();
}, [load]);
const approve = async () => {
if (!linkId) return;
setPhase("approving");
try {
const userId = getStoredUserId();
if (!userId) throw new Error("not authenticated");
const session = await getDeviceLinkSession(linkId);
if (!session) throw new Error("session gone");
const { privateKey } = await ensureIdentityKeys(userId);
await approveDeviceLink(linkId, privateKey, session.ephemeralPublicKey);
setPhase("done");
toast.success(t("settings.edit.linkDevice.approveSuccess"));
} catch {
toast.error(t("settings.edit.linkDevice.approveError"));
setPhase("confirm");
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.linkDevice.approveTitle")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{phase === "loading" && (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
)}
{phase === "invalid" && (
<p className="text-sm text-red-500">
{t("settings.edit.linkDevice.invalidLink")}
</p>
)}
{(phase === "confirm" || phase === "approving") && (
<>
<p className="mb-6 text-sm text-neutral-600 dark:text-neutral-300">
{t("settings.edit.linkDevice.approveHint")}
</p>
<button
type="button"
onClick={() => void approve()}
disabled={phase === "approving"}
className="mb-3 w-full rounded-2xl bg-[#0095f6] px-4 py-3 text-sm font-semibold text-white disabled:opacity-60"
>
{phase === "approving"
? t("common.loading")
: t("settings.edit.linkDevice.approveButton")}
</button>
<button
type="button"
onClick={() => router.push("/settings/edit")}
className="text-xs text-neutral-400"
>
{t("settings.edit.linkDevice.cancel")}
</button>
</>
)}
{phase === "done" && (
<p className="text-sm font-semibold text-[#0C8002]">
{t("settings.edit.linkDevice.approveSuccess")}
</p>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { getStoredUserId } from "@/lib/auth/session";
import {
startDeviceLinkSession,
pollDeviceLinkSession,
finishDeviceLink,
} from "@/lib/e2ee/deviceLink";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const POLL_INTERVAL_MS = 2500;
const SESSION_TTL_MS = 5 * 60 * 1000;
type Phase = "loading" | "ready" | "linking" | "done" | "expired" | "error";
export default function LinkDevicePage() {
const { t } = useTranslation("common");
const router = useRouter();
const [phase, setPhase] = useState<Phase>("loading");
const [linkId, setLinkId] = useState<string | null>(null);
const [approveUrl, setApproveUrl] = useState<string | null>(null);
const ephemeralPrivateKeyRef = useRef<CryptoKey | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const expireTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
if (expireTimerRef.current) clearTimeout(expireTimerRef.current);
pollTimerRef.current = null;
expireTimerRef.current = null;
};
const start = useCallback(async () => {
clearTimers();
setPhase("loading");
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);
setPhase("ready");
pollTimerRef.current = setInterval(async () => {
try {
const wrappedIdentityKey = await pollDeviceLinkSession(id);
if (!wrappedIdentityKey) return;
clearTimers();
setPhase("linking");
const userId = getStoredUserId();
if (!userId || !ephemeralPrivateKeyRef.current) {
throw new Error("missing session state");
}
await finishDeviceLink(
userId,
wrappedIdentityKey,
ephemeralPrivateKeyRef.current
);
setPhase("done");
toast.success(t("settings.edit.linkDevice.linkSuccess"));
} catch {
setPhase("error");
}
}, POLL_INTERVAL_MS);
expireTimerRef.current = setTimeout(() => {
clearTimers();
setPhase((prev) => (prev === "ready" ? "expired" : prev));
}, SESSION_TTL_MS);
} catch {
setPhase("error");
}
}, [t]);
useEffect(() => {
void start();
return () => clearTimers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const copyLink = async () => {
if (!approveUrl) return;
try {
await navigator.clipboard.writeText(approveUrl);
toast.success(t("settings.edit.linkDevice.linkCopied"));
} catch {
toast.error(t("settings.edit.linkDevice.copyFailed"));
}
};
const qrCodeUrl = approveUrl
? `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(
approveUrl
)}`
: null;
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.nav.linkDevice")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{phase === "loading" && (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
)}
{phase === "error" && (
<>
<p className="mb-6 text-sm text-red-500">
{t("settings.edit.linkDevice.startError")}
</p>
<button
type="button"
onClick={() => void start()}
className="rounded-2xl border-2 border-[#0095f6] px-4 py-2 text-sm font-semibold text-[#0095f6]"
>
{t("settings.edit.linkDevice.retry")}
</button>
</>
)}
{phase === "expired" && (
<>
<p className="mb-6 text-sm text-neutral-600 dark:text-neutral-300">
{t("settings.edit.linkDevice.expired")}
</p>
<button
type="button"
onClick={() => void start()}
className="rounded-2xl border-2 border-[#0095f6] px-4 py-2 text-sm font-semibold text-[#0095f6]"
>
{t("settings.edit.linkDevice.retry")}
</button>
</>
)}
{(phase === "ready" || phase === "linking") && qrCodeUrl && (
<>
<p className="mb-4 text-sm text-neutral-600 dark:text-neutral-300">
{t("settings.edit.linkDevice.scanHint")}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrCodeUrl}
alt={t("settings.edit.nav.linkDevice")}
width={240}
height={240}
className="mb-4 rounded-lg bg-white p-2"
/>
<button
type="button"
onClick={copyLink}
className="mb-2 text-xs text-[#0033EA] underline"
>
{t("settings.edit.linkDevice.copyLink")}
</button>
<p className="mt-4 text-xs text-neutral-400">
{phase === "linking"
? t("settings.edit.linkDevice.linking")
: t("settings.edit.linkDevice.waiting")}
</p>
</>
)}
{phase === "done" && (
<>
<p className="mb-6 text-sm font-semibold text-[#0C8002]">
{t("settings.edit.linkDevice.linkSuccess")}
</p>
<button
type="button"
onClick={() => router.push("/settings/chats")}
className="rounded-2xl border-2 border-[#0095f6] px-4 py-2 text-sm font-semibold text-[#0095f6]"
>
{t("settings.edit.linkDevice.goToChats")}
</button>
</>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -9,7 +9,6 @@ import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import UserDetails from "@/components/settings/UserDetails";
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
import { staticIconUrl } from "@/components/main/BaseUrl";
import { useTranslation } from "react-i18next";
@@ -19,6 +18,8 @@ const EDIT_NAV_KEY: Record<string, string> = {
"/google-account": "googleAccount",
"/instagram-import": "instagramImport",
"/two-factor": "twoFactor",
"/link-device": "linkDevice",
"/analytics": "analytics",
"/avatar": "avatar",
"/Authentication": "authentication",
"/expertise": "expertise",
@@ -50,7 +51,6 @@ function EditPage() {
<PageTitle>{t("settings.nav.edit")}</PageTitle>
<div>
<UserDetails />
<AccountAnalyticsWidget />
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
{editUserNavLinks?.map((item) => (
<Link

View File

@@ -4,7 +4,6 @@ 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";
@@ -158,8 +157,6 @@ export default function UserSettingsPage() {
</div>
)}
<AccountAnalyticsWidget />
<LanguageSettingRow />
<Link
@@ -178,6 +175,14 @@ export default function UserSettingsPage() {
<span className="text-neutral-400"></span>
</Link>
<Link
href="/settings/edit/analytics"
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.edit.nav.analytics")}</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

@@ -9,9 +9,15 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import SellerOrderItem from "@/components/shops/orders/SellerOrderItem";
import BuyerOrderItem from "@/components/shops/orders/BuyerOrderItem";
import ShopIncomeSettlement from "@/components/shops/wallet/ShopIncomeSettlement";
import ShopSalesHistory from "@/components/shops/orders/ShopSalesHistory";
import ShopPerformanceWidget from "@/components/shops/wallet/ShopPerformanceWidget";
import ShopPurchaseHistory from "@/components/shops/orders/ShopPurchaseHistory";
import ShopCartTab from "@/components/shops/ShopCartTab";
import ShopFavoritesTab from "@/components/shops/ShopFavoritesTab";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { toggleBtnClass, filterChipClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
@@ -22,6 +28,30 @@ import { useTranslation } from "react-i18next";
const FILTER_SELLER = "seller";
const FILTER_BUYER = "buyer";
const SELLER_TAB_ORDERS = "orders";
const SELLER_TAB_INCOME = "income";
const SELLER_TAB_SALES_HISTORY = "salesHistory";
const SELLER_TAB_PERFORMANCE = "performance";
const SELLER_TABS = [
SELLER_TAB_ORDERS,
SELLER_TAB_INCOME,
SELLER_TAB_SALES_HISTORY,
SELLER_TAB_PERFORMANCE,
] as const;
type SellerTab = (typeof SELLER_TABS)[number];
const BUYER_TAB_ORDERS = "orders";
const BUYER_TAB_CART = "cart";
const BUYER_TAB_FAVORITES = "favorites";
const BUYER_TAB_PURCHASE_HISTORY = "purchaseHistory";
const BUYER_TABS = [
BUYER_TAB_ORDERS,
BUYER_TAB_CART,
BUYER_TAB_FAVORITES,
BUYER_TAB_PURCHASE_HISTORY,
] as const;
type BuyerTab = (typeof BUYER_TABS)[number];
type MyShop = {
_id: string;
name: string;
@@ -37,6 +67,8 @@ export default function ShopSettingsPage() {
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const [sellerTab, setSellerTab] = useState<SellerTab>(SELLER_TAB_ORDERS);
const [buyerTab, setBuyerTab] = useState<BuyerTab>(BUYER_TAB_ORDERS);
useEffect(() => {
request<{ shops: MyShop[] }>("GET", "/shops/mine")
@@ -147,9 +179,45 @@ export default function ShopSettingsPage() {
</RoundedButton>
</div>
{filter === FILTER_SELLER && (
<div className="mx-auto mt-6 flex w-full max-w-md gap-2 overflow-x-auto pb-1 scrollbar-none">
{SELLER_TABS.map((tabId) => (
<button
key={tabId}
type="button"
onClick={() => setSellerTab(tabId)}
className={filterChipClass(sellerTab === tabId, "shrink-0")}
>
{t(`shops.sellerTabs.${tabId}`)}
</button>
))}
</div>
)}
{filter === FILTER_BUYER && (
<div className="mx-auto mt-6 flex w-full max-w-md gap-2 overflow-x-auto pb-1 scrollbar-none">
{BUYER_TABS.map((tabId) => (
<button
key={tabId}
type="button"
onClick={() => setBuyerTab(tabId)}
className={filterChipClass(buyerTab === tabId, "shrink-0")}
>
{t(`shops.buyerTabs.${tabId}`)}
</button>
))}
</div>
)}
<div className="mx-auto mt-10 w-full max-w-md">
{filter === FILTER_SELLER ? (
sellerOrdersLoading ? (
sellerTab === SELLER_TAB_INCOME ? (
<ShopIncomeSettlement />
) : sellerTab === SELLER_TAB_SALES_HISTORY ? (
<ShopSalesHistory />
) : sellerTab === SELLER_TAB_PERFORMANCE ? (
<ShopPerformanceWidget />
) : sellerOrdersLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : sellerOrders.length === 0 ? (
<p className="text-center text-neutral-500">
@@ -160,6 +228,12 @@ export default function ShopSettingsPage() {
<SellerOrderItem key={order._id} order={order} />
))
)
) : buyerTab === BUYER_TAB_CART ? (
<ShopCartTab />
) : buyerTab === BUYER_TAB_FAVORITES ? (
<ShopFavoritesTab />
) : buyerTab === BUYER_TAB_PURCHASE_HISTORY ? (
<ShopPurchaseHistory />
) : buyerOrdersLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : buyerOrders.length === 0 ? (

View File

@@ -8,7 +8,6 @@ import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
import BoldIcon from "@/components/ui/BoldIcon";
import TabNavigation from "@/components/TabNavigation";
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";
@@ -73,12 +72,23 @@ export default function ListingDetailClient() {
setSelectedSize(firstVariant.size || null);
setSelectedWeight(firstVariant.weight || null);
}
if (data) setFavorite(isFavorite(data._id));
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.listingId]);
useEffect(() => {
if (!listing?._id) return;
request<{ listings: { _id: string }[] }>("GET", "/shops/favorites", null, {
noToast: true,
})
.then((res) => {
setFavorite((res?.listings || []).some((l) => l._id === listing._id));
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing?._id]);
const shopInfo: ShopHeader | null =
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
@@ -199,17 +209,37 @@ export default function ListingDetailClient() {
}
};
const handleAddToCart = () => {
const handleAddToCart = async () => {
if (!listing || !matchedVariant) return;
addToCart(listing._id, matchedVariant._id, quantity);
toast.success(t("shops.addToCartSuccess"));
try {
await request("POST", "/shops/cart", {
listingId: listing._id,
variantId: matchedVariant._id,
quantity,
});
toast.success(t("shops.addToCartSuccess"));
} catch {
toast.error(t("shops.unknownError"));
}
};
const handleToggleFavorite = () => {
const handleToggleFavorite = async () => {
if (!listing) return;
const next = toggleFavorite(listing._id);
setFavorite(next);
toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites"));
const wasFavorite = favorite;
setFavorite(!wasFavorite);
try {
const res = await request<{ favorited: boolean }>(
"POST",
"/shops/favorites/toggle",
{ listingId: listing._id }
);
toast.success(
res?.favorited ? t("shops.addedToFavorites") : t("shops.removedFromFavorites")
);
} catch {
setFavorite(wasFavorite);
toast.error(t("shops.unknownError"));
}
};
if (!listing) return null;

View File

@@ -20,6 +20,7 @@ import {
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import BoldIcon from "@/components/ui/BoldIcon";
import { useUserById } from "@/hooks/getUserById";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
@@ -425,11 +426,17 @@ function MainModelCard({
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-[#0090ff] text-xl font-semibold">
<span className="text-[#0090ff] text-xl font-semibold flex items-center">
{formatCoursePriceLabel(postData, priceLang)}
{!postData.is_free && postData.offer && Number(postData.offer) > 0 ? (
<span className="text-[#ff0000] text-xl font-semibold mr-2">
%{postData.offer}
<span className="mr-2 inline-flex items-center gap-1 rounded-full bg-[#ff5c00] px-2 py-0.5 text-xs font-bold text-white">
{t("shops.discountPercent", { percent: postData.offer })}
<BoldIcon
name="receipt-discount"
size={12}
tinted
className="text-white"
/>
</span>
) : null}
</span>

View File

@@ -0,0 +1,76 @@
"use client";
import { staticIconUrl } from "@/components/main/BaseUrl";
import { saveBookingWizardId } from "@/hooks/useBookingWizardId";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
type BookingListCardProps = {
config: {
_id: string;
name: string;
services: { title: string }[];
status: "draft" | "active" | "inactive";
};
};
const STATUS_TEXT_COLOR: Record<string, string> = {
active: "text-green-600",
inactive: "text-red-500",
draft: "text-neutral-400",
};
export default function BookingListCard({ config }: BookingListCardProps) {
const { t } = useTranslation("common");
const router = useRouter();
const openConfig = () => {
saveBookingWizardId(config._id);
router.push("/settings/booking/new/services?edit=1");
};
return (
<button
type="button"
onClick={openConfig}
className="gentle-transition flex w-full items-center gap-3 rounded-[28px] border border-neutral-200 p-3 text-right active:scale-[0.99] dark:border-neutral-700"
>
<span className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
<Image
src={staticIconUrl("/images/icons/archive-book.svg")}
width={28}
height={28}
alt=""
className="dark:invert"
/>
</span>
<span className="flex min-w-0 flex-1 flex-col items-end gap-1.5 text-right">
<div className="flex w-full items-center justify-between">
<span className="line-clamp-1 text-base font-bold">{config.name}</span>
<span
className={cn(
"shrink-0 text-xs font-semibold",
STATUS_TEXT_COLOR[config.status] || STATUS_TEXT_COLOR.draft
)}
>
{config.status === "active"
? t("booking.statusActive")
: config.status === "inactive"
? t("booking.statusInactive")
: t("booking.statusDraft")}
</span>
</div>
<div className="flex w-full items-center justify-between">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t("booking.servicesCount", {
count: config.services?.length || 0,
})}
</span>
</div>
</span>
</button>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import UserInfo from "@/components/main/UserInfo";
import { useTranslation } from "react-i18next";
export type BookingBuyer = {
_id: string;
first_name?: string;
last_name?: string;
user_name?: string;
profile_image?: string;
user_level?: string;
is_verified?: string;
verify_badge?: string;
};
export type ReceivedBooking = {
_id: string;
buyer: BookingBuyer;
serviceTitle: string;
servicePrice: number;
date: string;
startTime: string;
endTime: string;
status: "pending_payment" | "confirmed" | "cancelled";
createdAt: string;
};
export function bookingStatusColor(status: ReceivedBooking["status"]) {
return status === "confirmed"
? "#008D0E"
: status === "cancelled"
? "#BA4141"
: "#BFAF19";
}
type BookingReservationItemProps = {
booking: ReceivedBooking;
onClick?: (booking: ReceivedBooking) => void;
};
export default function BookingReservationItem({
booking,
onClick,
}: BookingReservationItemProps) {
const { t } = useTranslation("common");
const statusLabel =
booking.status === "confirmed"
? t("booking.statusConfirmed")
: booking.status === "cancelled"
? t("booking.statusCancelled")
: t("booking.statusPendingPayment");
return (
<div
className="mb-4 block w-full cursor-pointer rounded-3xl border border-border-primary-light p-4 font-semibold"
onClick={() => onClick?.(booking)}
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<UserInfo
profile_image={booking.buyer?.profile_image}
user_level={booking.buyer?.user_level}
first_name={booking.buyer?.first_name}
last_name={booking.buyer?.last_name}
user_name={booking.buyer?.user_name}
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]"
style={{ color: bookingStatusColor(booking.status) }}
>
<span>{statusLabel}</span>
</div>
</div>
<div className="mt-3 flex flex-col gap-1 border-t border-neutral-200 pt-3 text-neutral-600 dark:border-neutral-700 dark:text-neutral-300">
<div className="flex justify-between">
<span>{booking.serviceTitle}</span>
<span>
{Number(booking.servicePrice).toLocaleString()} {t("settings.toman")}
</span>
</div>
<div className="flex justify-between">
<span>{new Date(booking.date).toISOString().slice(0, 10)}</span>
<span>
{booking.startTime} - {booking.endTime}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import BookingReservationItem, {
type ReceivedBooking,
} from "./BookingReservationItem";
type BookingStatusModalProps = {
isOpen: boolean;
onClose: () => void;
booking: ReceivedBooking | null;
onUpdated: () => void;
};
export default function BookingStatusModal({
isOpen,
onClose,
booking,
onUpdated,
}: BookingStatusModalProps) {
const { t } = useTranslation("common");
const { request, loading } = useAxios();
const cancelBooking = async () => {
if (!booking) return;
try {
await request("POST", `/bookings/reservations/${booking._id}/status`, {
action: "cancel",
});
toast.success(t("booking.cancelSuccess"));
onUpdated();
onClose();
} catch (err: unknown) {
const message = (
err as { response?: { data?: { message?: string } } }
)?.response?.data?.message;
toast.error(message || t("booking.cancelError"));
}
};
return (
<Modal height="440px" isOpen={isOpen} onClose={onClose}>
<div className="mt-5 flex flex-col items-center gap-6">
{booking && <BookingReservationItem booking={booking} />}
{booking?.status === "cancelled" ? (
<p className="text-sm text-neutral-500">
{t("booking.alreadyCancelled")}
</p>
) : (
<>
<p className="text-center text-sm text-neutral-500">
{t("booking.cancelConfirmHint")}
</p>
<RoundedButton
className="h-9 w-40"
disabled={loading}
onClick={() => void cancelBooking()}
>
{t("booking.cancelAction")}
</RoundedButton>
</>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import useAxios from "@/hooks/useAxios";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BookingReservationItem, {
type ReceivedBooking,
} from "./BookingReservationItem";
import BookingStatusModal from "./BookingStatusModal";
export default function IncomingBookingsList() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [bookings, setBookings] = useState<ReceivedBooking[] | null>(null);
const [itemToAction, setItemToAction] = useState<ReceivedBooking | null>(
null
);
const [showModal, setShowModal] = useState(false);
const load = useCallback(() => {
request<{ bookings: ReceivedBooking[] }>(
"GET",
"/bookings/reservations?role=provider"
).then((res) => setBookings(res?.bookings || []));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
load();
}, [load]);
const openBooking = (booking: ReceivedBooking) => {
setItemToAction(booking);
setShowModal(true);
};
return (
<div className="mt-8 flex flex-col gap-4">
{bookings === null ? (
<p className="py-10 text-center text-gray-500">
{t("common.loading")}
</p>
) : bookings.length === 0 ? (
<p className="py-10 text-center text-gray-500">
{t("booking.bookingsListEmpty")}
</p>
) : (
bookings.map((booking) => (
<BookingReservationItem
key={booking._id}
booking={booking}
onClick={openBooking}
/>
))
)}
{showModal && (
<BookingStatusModal
isOpen={showModal}
onClose={() => setShowModal(false)}
booking={itemToAction}
onUpdated={load}
/>
)}
</div>
);
}

View File

@@ -3,17 +3,25 @@
import { useAppLanguage } from "@/contexts/LanguageProvider";
import {
ENABLED_LANGUAGES,
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
LANGUAGE_REGISTRY,
type AppLanguage,
} from "@/lib/i18n/registry";
import useAxios from "@/hooks/useAxios";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const OTHER_LANGUAGES = SUPPORTED_LANGUAGES.filter(
(code) => !ENABLED_LANGUAGES.includes(code)
);
export default function LanguageSettingRow() {
const { t } = useTranslation("common");
const { language, setLanguage } = useAppLanguage();
const { request } = useAxios();
const [showOthers, setShowOthers] = useState(false);
const handleChange = async (next: AppLanguage) => {
if (next === language) return;
@@ -63,6 +71,36 @@ export default function LanguageSettingRow() {
);
})}
</div>
{!showOthers ? (
<button
type="button"
onClick={() => setShowOthers(true)}
className="mt-3 w-full rounded-lg border border-neutral-200 px-3 py-2 text-xs font-semibold text-neutral-600 dark:border-neutral-700 dark:text-neutral-300"
>
{t("language.otherLanguages")}
</button>
) : (
<div className="mt-3 grid grid-cols-2 gap-2">
{OTHER_LANGUAGES.map((code) => {
const def = LANGUAGE_REGISTRY[code];
return (
<button
key={code}
type="button"
dir={def.direction}
onClick={() => toast(t("language.comingSoon"))}
className="flex items-center justify-between rounded-lg border border-neutral-200 px-3 py-2 text-sm text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
>
<span>{def.nativeLabel}</span>
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[9px] font-semibold dark:bg-neutral-800">
{t("language.comingSoonBadge")}
</span>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useCallback, useEffect, useState } from "react";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import { useTranslation } from "react-i18next";
type SeriesPoint = { _id: string; revenue: number; count: number };
type TopItem = {
id: string;
title: string;
image: string | null;
metric: number;
};
type SectionAnalyticsResponse = {
range: string;
series: SeriesPoint[];
totals: Record<string, number>;
topItems: TopItem[];
rating: { average: number; count: number } | null;
statusBreakdown?: Record<string, number>;
};
const RANGE_OPTIONS = ["7d", "30d", "90d", "all"] as const;
type RangeOption = (typeof RANGE_OPTIONS)[number];
const KNOWN_TOTAL_KEYS = ["totalRevenue", "totalCount"];
type SectionAnalyticsWidgetProps = {
endpoint: string;
/** Whether topItems.image is a stored path needing IMAGE_BASE_URL (false for external/no-image items). */
useImageBaseUrl?: boolean;
statusLabelPrefix?: string;
};
export default function SectionAnalyticsWidget({
endpoint,
useImageBaseUrl = true,
statusLabelPrefix,
}: SectionAnalyticsWidgetProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [range, setRange] = useState<RangeOption>("30d");
const [data, setData] = useState<SectionAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
request<SectionAnalyticsResponse>("GET", `${endpoint}?range=${range}`, null, {
noToast: true,
})
.then((res) => setData(res || null))
.catch(() => setData(null))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, range]);
useEffect(() => {
load();
}, [load]);
const totals = data?.totals || {};
const extraKeys = Object.keys(totals).filter((k) => !KNOWN_TOTAL_KEYS.includes(k));
const series = data?.series || [];
return (
<div className="mx-auto w-full max-w-md">
<div className="my-4 flex justify-center gap-1 rounded-full bg-neutral-100 p-1 dark:bg-neutral-800">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
onClick={() => setRange(opt)}
className={`flex-1 rounded-full px-3 py-1.5 text-xs font-semibold transition ${
range === opt
? "bg-white text-neutral-900 shadow dark:bg-neutral-700 dark:text-white"
: "text-neutral-500"
}`}
>
{t(`settings.analytics.range.${opt}`)}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2 text-center">
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">
{(totals.totalRevenue || 0).toLocaleString()}
</p>
<p className="text-[11px] text-neutral-500">
{t("settings.edit.analyticsHub.metrics.totalRevenue")}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">{totals.totalCount ?? 0}</p>
<p className="text-[11px] text-neutral-500">
{t("settings.edit.analyticsHub.metrics.totalCount")}
</p>
</div>
{extraKeys.map((key) => (
<div
key={key}
className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<p className="text-lg font-bold">{totals[key] ?? 0}</p>
<p className="text-[11px] text-neutral-500">
{t(`settings.edit.analyticsHub.metrics.${key}`, key)}
</p>
</div>
))}
{data?.rating && (
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">{data.rating.average.toFixed(1)}</p>
<p className="text-[11px] text-neutral-500">
{t("settings.edit.analyticsHub.metrics.rating", { count: data.rating.count })}
</p>
</div>
)}
</div>
{data?.statusBreakdown && (
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(data.statusBreakdown).map(([status, count]) => (
<span
key={status}
className="rounded-full border border-neutral-200 px-3 py-1 text-xs dark:border-neutral-700"
>
{statusLabelPrefix ? t(`${statusLabelPrefix}.${status}`) : status}: {count}
</span>
))}
</div>
)}
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-3 text-sm font-bold">
{t("settings.edit.analyticsHub.metrics.revenueChartTitle")}
</h3>
{loading ? (
<div className="h-48 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
) : series.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("settings.edit.analyticsHub.metrics.empty")}
</p>
) : (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={series} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="sectionRevenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0095f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#0095f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
<XAxis dataKey="_id" tick={{ fontSize: 9 }} />
<YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
<Tooltip />
<Area
type="monotone"
dataKey="revenue"
stroke="#0095f6"
strokeWidth={2}
fill="url(#sectionRevenueGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
{Boolean(data?.topItems?.length) && (
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-3 text-sm font-bold">
{t("settings.edit.analyticsHub.metrics.topItemsTitle")}
</h3>
<div className="flex flex-col gap-2">
{data!.topItems.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 rounded-xl border border-neutral-100 p-2 dark:border-neutral-800"
>
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-neutral-100 dark:bg-neutral-800">
{item.image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={useImageBaseUrl ? IMAGE_BASE_URL + item.image : item.image}
alt={item.title}
className="h-full w-full object-cover"
/>
)}
</span>
<span className="flex-1 truncate text-xs font-semibold">{item.title}</span>
<span className="shrink-0 text-xs text-neutral-500">
{item.metric.toLocaleString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type CartItem = {
_id: string;
listing: {
_id: string;
title: string;
images: string[];
primaryImageIndex: number;
status: string;
};
variant: {
_id: string;
color?: string | null;
size?: string | null;
weight?: string | null;
price: number;
discount_price?: number | null;
stock: number;
} | null;
quantity: number;
unitPrice: number;
subtotal: number;
unavailable: boolean;
};
export default function ShopCartTab() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [items, setItems] = useState<CartItem[] | null>(null);
const [total, setTotal] = useState(0);
const load = useCallback(() => {
request<{ items: CartItem[]; total: number }>("GET", "/shops/cart")
.then((res) => {
setItems(res?.items || []);
setTotal(res?.total || 0);
})
.catch(() => setItems([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
load();
}, [load]);
const updateQuantity = async (item: CartItem, quantity: number) => {
try {
await request("PATCH", `/shops/cart/${item._id}`, { quantity });
load();
} catch {
toast.error(t("shops.unknownError"));
}
};
const removeItem = async (item: CartItem) => {
try {
await request("DELETE", `/shops/cart/${item._id}`);
load();
} catch {
toast.error(t("shops.unknownError"));
}
};
return (
<div className="mx-auto mt-6 w-full max-w-md">
{items === null ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("shops.cart.empty")}
</p>
) : (
<>
<div className="flex flex-col gap-2">
{items.map((item) => (
<div
key={item._id}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"
>
<button
type="button"
onClick={() =>
router.push(`/shops/product/${item.listing._id}`)
}
className="h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800"
>
{item.listing.images?.[item.listing.primaryImageIndex] && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={
IMAGE_BASE_URL +
item.listing.images[item.listing.primaryImageIndex]
}
alt=""
className="h-full w-full object-cover"
/>
)}
</button>
<div className="flex-1">
<p className="line-clamp-1 text-xs font-semibold">
{item.listing.title}
</p>
{item.unavailable ? (
<p className="mt-1 text-[11px] text-red-500">
{t("shops.cart.unavailable")}
</p>
) : (
<p className="mt-1 text-[11px] text-neutral-500">
{item.unitPrice.toLocaleString()} {t("settings.toman")}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => updateQuantity(item, item.quantity - 1)}
className="h-6 w-6 rounded-full border border-neutral-200 text-xs dark:border-neutral-700"
>
</button>
<span className="w-5 text-center text-xs">{item.quantity}</span>
<button
type="button"
onClick={() => updateQuantity(item, item.quantity + 1)}
disabled={Boolean(item.variant && item.quantity >= item.variant.stock)}
className="h-6 w-6 rounded-full border border-neutral-200 text-xs disabled:opacity-40 dark:border-neutral-700"
>
+
</button>
</div>
</div>
<button
type="button"
onClick={() => removeItem(item)}
aria-label={t("shops.cart.remove")}
className="shrink-0 p-2"
>
<BoldIcon name="trash" size={18} tinted className="text-red-500" />
</button>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between rounded-2xl border border-neutral-200 p-4 text-sm font-bold dark:border-neutral-700">
<span>{t("shops.cart.total")}</span>
<span>
{total.toLocaleString()} {t("settings.toman")}
</span>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { IShopProductListing } from "@/types/types";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
function cheapestPrice(listing: IShopProductListing): number | null {
if (!listing.variants || listing.variants.length === 0) return null;
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
}
export default function ShopFavoritesTab() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
const load = useCallback(() => {
request<{ listings: IShopProductListing[] }>("GET", "/shops/favorites")
.then((res) => setListings(res?.listings || []))
.catch(() => setListings([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
load();
}, [load]);
const removeFavorite = async (listingId: string) => {
setListings((prev) => prev?.filter((l) => l._id !== listingId) || null);
try {
await request("POST", "/shops/favorites/toggle", { listingId });
} catch {
load();
}
};
if (listings === null) {
return (
<p className="py-10 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
);
}
if (listings.length === 0) {
return (
<p className="py-10 text-center text-sm text-neutral-500">
{t("shops.favoritesTab.empty")}
</p>
);
}
return (
<div className="mx-auto mt-6 w-full max-w-md">
<div className="columns-2 gap-2">
{listings.map((listing) => {
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
const price = cheapestPrice(listing);
return (
<article key={listing._id} className="mb-2 break-inside-avoid">
<div className="relative w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900">
<button
type="button"
onClick={() => router.push(`/shops/product/${listing._id}`)}
className="block aspect-square w-full"
>
{primaryImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + primaryImage}
alt={listing.title}
className="h-full w-full object-cover"
/>
)}
</button>
<button
type="button"
onClick={() => removeFavorite(listing._id)}
aria-label={t("shops.favoritesTab.remove")}
className="absolute left-2 top-2 rounded-full bg-black/60 p-1.5"
>
<BoldIcon name="heart" size={14} tinted className="text-white" />
</button>
</div>
<div className="flex flex-col gap-1 px-0.5 py-1.5">
<span className="truncate text-xs font-semibold">
{listing.title}
</span>
{price != null && (
<span className="text-[11px] text-neutral-500">
{t("shops.priceLabel", { amount: price.toLocaleString() })}
</span>
)}
</div>
</article>
);
})}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ShopProductAuthor from "@/components/shops/ShopProductAuthor";
import BoldIcon from "@/components/ui/BoldIcon";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -22,6 +23,20 @@ function cheapestPrice(listing: IShopProductListing): number | null {
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
}
/** The discounted variant with the deepest cut, if any variant currently has one. */
function bestDiscount(
listing: IShopProductListing
): { percent: number } | null {
if (!listing.variants || listing.variants.length === 0) return null;
let best: number | null = null;
for (const v of listing.variants) {
if (!v.discount_price || v.discount_price >= v.price || v.price <= 0) continue;
const percent = Math.round(((v.price - v.discount_price) / v.price) * 100);
if (best == null || percent > best) best = percent;
}
return best != null ? { percent: best } : null;
}
const ASPECT_RATIOS = [
"aspect-[2/3]",
"aspect-[3/4]",
@@ -100,6 +115,7 @@ export default function ShopProductGrid({
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
const price = cheapestPrice(listing);
const discount = bestDiscount(listing);
const cardShop =
typeof listing.shop === "object" && listing.shop
? listing.shop
@@ -131,6 +147,17 @@ export default function ShopProductGrid({
{t(`shops.status.${listing.status}`)}
</span>
)}
{discount && (
<span className="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-[#ff5c00] px-2 py-0.5 text-[10px] font-bold text-white">
{t("shops.discountPercent", { percent: discount.percent })}
<BoldIcon
name="receipt-discount"
size={12}
tinted
className="text-white"
/>
</span>
)}
</div>
</button>
<div className="flex flex-col gap-1 px-0.5 py-1.5">

View File

@@ -10,7 +10,6 @@ import BoldIcon from "@/components/ui/BoldIcon";
import PageLoader from "@/components/ui/PageLoader";
import { REELS_SCROLL_CLASS, useReelsSnapScroll } from "@/hooks/useReelsSnapScroll";
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";
@@ -65,12 +64,20 @@ export default function ShopReelsView({
const [sendListing, setSendListing] = useState<IShopProductListing | null>(null);
const [commentListingId, setCommentListingId] = useState<string | null>(null);
useEffect(() => {
request<{ listings: { _id: string }[] }>("GET", "/shops/favorites", null, {
noToast: true,
})
.then((res) => {
setFavoriteIds(new Set((res?.listings || []).map((l) => l._id)));
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (providedListings) {
setListings(providedListings);
setFavoriteIds(
new Set(providedListings.filter((l) => isFavorite(l._id)).map((l) => l._id))
);
return;
}
if (!shopId) return;
@@ -78,35 +85,53 @@ export default function ShopReelsView({
"GET",
`/shop-products?shopId=${shopId}&limit=100`
)
.then((res) => {
const docs = res?.docs || [];
setListings(docs);
setFavoriteIds(
new Set(docs.filter((l) => isFavorite(l._id)).map((l) => l._id))
);
})
.then((res) => setListings(res?.docs || []))
.catch(() => setListings([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shopId, providedListings]);
const handleToggleFavorite = (listingId: string) => {
const next = toggleFavorite(listingId);
const handleToggleFavorite = async (listingId: string) => {
const wasFavorited = favoriteIds.has(listingId);
setFavoriteIds((prev) => {
const nextSet = new Set(prev);
if (next) nextSet.add(listingId);
else nextSet.delete(listingId);
if (wasFavorited) nextSet.delete(listingId);
else nextSet.add(listingId);
return nextSet;
});
toast.success(
next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites")
);
try {
const res = await request<{ favorited: boolean }>(
"POST",
"/shops/favorites/toggle",
{ listingId }
);
toast.success(
res?.favorited ? t("shops.addedToFavorites") : t("shops.removedFromFavorites")
);
} catch {
// roll back on failure
setFavoriteIds((prev) => {
const nextSet = new Set(prev);
if (wasFavorited) nextSet.add(listingId);
else nextSet.delete(listingId);
return nextSet;
});
toast.error(t("shops.unknownError"));
}
};
const handleAddToCart = (listing: IShopProductListing) => {
const handleAddToCart = async (listing: IShopProductListing) => {
const variant = listing.variants?.[0];
if (!variant) return;
addToCart(listing._id, variant._id, 1);
toast.success(t("shops.addToCartSuccess"));
try {
await request("POST", "/shops/cart", {
listingId: listing._id,
variantId: variant._id,
quantity: 1,
});
toast.success(t("shops.addToCartSuccess"));
} catch {
toast.error(t("shops.unknownError"));
}
};
const initialIndex = useMemo(() => {

View File

@@ -0,0 +1,120 @@
"use client";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import DatePicker, { type DateObject } from "react-multi-date-picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import { useTranslation } from "react-i18next";
export type SalesHistoryFilters = {
startDate: string | null;
endDate: string | null;
statuses: string[];
};
const ORDER_STATUSES = [
"pending_payment",
"on_hold",
"processing",
"shipped",
"ready_for_pickup",
"completed",
"cancelled",
"refunded",
"failed",
] as const;
type SalesHistoryFilterModalProps = {
isOpen: boolean;
onClose: () => void;
filters: SalesHistoryFilters;
onApply: (filters: SalesHistoryFilters) => void;
};
function toIsoDate(value: DateObject | null): string | null {
if (!value) return null;
return value.toDate().toISOString();
}
export default function SalesHistoryFilterModal({
isOpen,
onClose,
filters,
onApply,
}: SalesHistoryFilterModalProps) {
const { t } = useTranslation("common");
const toggleStatus = (status: string) => {
const next = filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status];
onApply({ ...filters, statuses: next });
};
return (
<Modal isOpen={isOpen} onClose={onClose} height="560px">
<div className="flex flex-col items-center gap-4 overflow-y-auto px-1">
<span className="font-semibold">{t("shops.salesHistory.filterTitle")}</span>
<div className="w-full max-w-xs">
<p className="mb-1 text-xs text-neutral-500">{t("shops.salesHistory.dateRangeLabel")}</p>
<DatePicker
range
calendar={persian}
locale={persian_fa}
value={
[filters.startDate, filters.endDate].filter(Boolean) as unknown as DateObject[]
}
onChange={(value) => {
const arr = Array.isArray(value) ? value : value ? [value] : [];
onApply({
...filters,
startDate: toIsoDate(arr[0] || null),
endDate: toIsoDate(arr[1] || null),
});
}}
calendarPosition="bottom-center"
placeholder={t("shops.salesHistory.dateRangePlaceholder")}
inputClass="w-full rounded-full border border-neutral-200 px-4 py-2 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div className="w-full max-w-xs">
<p className="mb-2 text-xs text-neutral-500">{t("shops.salesHistory.statusLabel")}</p>
<div className="flex flex-wrap gap-2">
{ORDER_STATUSES.map((status) => {
const active = filters.statuses.includes(status);
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={`rounded-full border px-3 py-1 text-xs font-semibold ${
active
? "border-transparent bg-[#0095f6] text-white"
: "border-neutral-200 text-neutral-600 dark:border-neutral-700 dark:text-neutral-300"
}`}
>
{t(`shops.orderStatus.${status}`)}
</button>
);
})}
</div>
</div>
<div className="mt-2 flex w-full max-w-xs gap-2">
<RoundedButton
className="flex-1"
onClick={() => onApply({ startDate: null, endDate: null, statuses: [] })}
>
{t("shops.salesHistory.clearFilter")}
</RoundedButton>
<RoundedButton variant="primary" className="flex-1" onClick={onClose}>
{t("shops.salesHistory.applyFilter")}
</RoundedButton>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import BoldIcon from "@/components/ui/BoldIcon";
import BuyerOrderItem from "@/components/shops/orders/BuyerOrderItem";
import SalesHistoryFilterModal, {
type SalesHistoryFilters,
} from "@/components/shops/orders/SalesHistoryFilterModal";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Order = any;
export default function ShopPurchaseHistory() {
const { t } = useTranslation("common");
const [showFilter, setShowFilter] = useState(false);
const [filters, setFilters] = useState<SalesHistoryFilters>({
startDate: null,
endDate: null,
statuses: [],
});
const params = useMemo(
() => ({
role: "buyer",
...(filters.startDate ? { startDate: filters.startDate } : {}),
...(filters.endDate ? { endDate: filters.endDate } : {}),
...(filters.statuses.length ? { status: filters.statuses.join(",") } : {}),
}),
[filters]
);
const { data, isLoading } = useInfiniteScroll({
endpoint: "/orders",
queryKey: [
"shop-purchase-history",
filters.startDate || "",
filters.endDate || "",
filters.statuses.join(","),
],
params,
});
const orders: Order[] = data?.pages.flatMap((page) => page.docs || []) || [];
const hasActiveFilter =
Boolean(filters.startDate || filters.endDate) || filters.statuses.length > 0;
return (
<div className="mx-auto mt-6 w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-bold">{t("shops.purchaseHistory.title")}</span>
<button
type="button"
onClick={() => setShowFilter(true)}
className="relative rounded-full border border-neutral-200 p-2 dark:border-neutral-700"
aria-label={t("shops.salesHistory.filterTitle")}
>
<BoldIcon name="filter" size={16} tinted />
{hasActiveFilter && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-[#0095f6]" />
)}
</button>
</div>
{isLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : orders.length === 0 ? (
<p className="text-center text-neutral-500">
{t("shops.purchaseHistory.empty")}
</p>
) : (
orders.map((order) => <BuyerOrderItem key={order._id} order={order} />)
)}
<SalesHistoryFilterModal
isOpen={showFilter}
onClose={() => setShowFilter(false)}
filters={filters}
onApply={setFilters}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import BoldIcon from "@/components/ui/BoldIcon";
import SellerOrderItem from "@/components/shops/orders/SellerOrderItem";
import SalesHistoryFilterModal, {
type SalesHistoryFilters,
} from "@/components/shops/orders/SalesHistoryFilterModal";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Order = any;
export default function ShopSalesHistory() {
const { t } = useTranslation("common");
const [showFilter, setShowFilter] = useState(false);
const [filters, setFilters] = useState<SalesHistoryFilters>({
startDate: null,
endDate: null,
statuses: [],
});
const params = useMemo(
() => ({
role: "seller",
...(filters.startDate ? { startDate: filters.startDate } : {}),
...(filters.endDate ? { endDate: filters.endDate } : {}),
...(filters.statuses.length ? { status: filters.statuses.join(",") } : {}),
}),
[filters]
);
const { data, isLoading } = useInfiniteScroll({
endpoint: "/orders",
queryKey: [
"shop-sales-history",
filters.startDate || "",
filters.endDate || "",
filters.statuses.join(","),
],
params,
});
const orders: Order[] = data?.pages.flatMap((page) => page.docs || []) || [];
const hasActiveFilter =
Boolean(filters.startDate || filters.endDate) || filters.statuses.length > 0;
return (
<div className="mx-auto mt-6 w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-bold">{t("shops.salesHistory.title")}</span>
<button
type="button"
onClick={() => setShowFilter(true)}
className="relative rounded-full border border-neutral-200 p-2 dark:border-neutral-700"
aria-label={t("shops.salesHistory.filterTitle")}
>
<BoldIcon name="filter" size={16} tinted />
{hasActiveFilter && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-[#0095f6]" />
)}
</button>
</div>
{isLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : orders.length === 0 ? (
<p className="text-center text-neutral-500">
{t("shops.salesHistory.empty")}
</p>
) : (
orders.map((order) => <SellerOrderItem key={order._id} order={order} />)
)}
<SalesHistoryFilterModal
isOpen={showFilter}
onClose={() => setShowFilter(false)}
filters={filters}
onApply={setFilters}
/>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import BoldIcon from "@/components/ui/BoldIcon";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import useAxios from "@/hooks/useAxios";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import Modal from "@/components/elements/Modal";
import DatePicker, { type DateObject } from "react-multi-date-picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
type WalletSummary = {
shop: { available: number; pending: number };
};
type WalletTransaction = {
_id: string;
type: "credit" | "debit";
amount: number;
description?: string | null;
createdAt: string;
shop?: { name: string; logo?: string | null } | null;
};
const TAB_RECEIPTS = "credit";
const TAB_PAYMENTS = "debit";
function toIsoDate(value: DateObject | null): string | null {
if (!value) return null;
return value.toDate().toISOString();
}
export default function ShopIncomeSettlement() {
const { t } = useTranslation("common");
const { request, loading } = useAxios();
const [summary, setSummary] = useState<WalletSummary | null>(null);
const [withdrawAmount, setWithdrawAmount] = useState("");
const [tab, setTab] = useState<typeof TAB_RECEIPTS | typeof TAB_PAYMENTS>(
TAB_RECEIPTS
);
const [transactions, setTransactions] = useState<WalletTransaction[] | null>(
null
);
const [showFilter, setShowFilter] = useState(false);
const [dateRange, setDateRange] = useState<{
startDate: string | null;
endDate: string | null;
}>({ startDate: null, endDate: null });
const loadSummary = useCallback(() => {
request<WalletSummary>("GET", "/wallet/summary")
.then((res) => setSummary(res))
.catch(() => setSummary(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadTransactions = useCallback(() => {
setTransactions(null);
const params = new URLSearchParams({ bucket: "shop", type: tab });
if (dateRange.startDate) params.set("startDate", dateRange.startDate);
if (dateRange.endDate) params.set("endDate", dateRange.endDate);
request<{ docs: WalletTransaction[] }>(
"GET",
`/wallet/transactions?${params.toString()}`
)
.then((res) => setTransactions(res?.docs || []))
.catch(() => setTransactions([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, dateRange]);
useEffect(() => {
loadSummary();
}, [loadSummary]);
useEffect(() => {
loadTransactions();
}, [loadTransactions]);
const handleWithdraw = async () => {
const amount = Number(withdrawAmount);
if (!amount || amount <= 0) return;
try {
await request("POST", "/wallet/withdraw", { bucket: "shop", amount });
toast.success(t("shops.withdrawalRequested"));
setWithdrawAmount("");
loadSummary();
loadTransactions();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const hasActiveFilter = Boolean(dateRange.startDate || dateRange.endDate);
return (
<div className="mx-auto mt-6 w-full max-w-md">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold">{t("shops.incomeSettlement.title")}</span>
<button
type="button"
onClick={() => setShowFilter(true)}
className="relative rounded-full border border-neutral-200 p-2 dark:border-neutral-700"
aria-label={t("shops.salesHistory.filterTitle")}
>
<BoldIcon name="filter" size={16} tinted />
{hasActiveFilter && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-[#0095f6]" />
)}
</button>
</div>
<div className="text-center">
<p className="text-xs text-neutral-500">{t("shops.availableToWithdraw")}</p>
<p className="text-2xl font-bold">
{(summary?.shop.available || 0).toLocaleString()} {t("settings.toman")}
</p>
<p className="mt-1 text-xs text-neutral-500">
{t("shops.pendingConfirmation")}:{" "}
{(summary?.shop.pending || 0).toLocaleString()} {t("settings.toman")}
</p>
</div>
<div className="mt-4 flex gap-2">
<RoundedInput
type="number"
inputMode="numeric"
placeholder={t("shops.withdrawAmountPlaceholder")}
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton variant="primary" disabled={loading} onClick={handleWithdraw}>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<RoundedButton
className={cn(toggleBtnClass(tab === TAB_RECEIPTS), "h-9")}
onClick={() => setTab(TAB_RECEIPTS)}
>
{t("shops.incomeSettlement.receipts")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(tab === TAB_PAYMENTS), "h-9")}
onClick={() => setTab(TAB_PAYMENTS)}
>
{t("shops.incomeSettlement.payments")}
</RoundedButton>
</div>
<div className="mt-4 flex flex-col gap-2">
{transactions === null ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : transactions.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("shops.incomeSettlement.empty")}
</p>
) : (
transactions.map((tx) => (
<div
key={tx._id}
className="flex items-center justify-between rounded-2xl border border-neutral-200 p-3 text-sm dark:border-neutral-700"
>
<div className="flex flex-col">
<span className="font-semibold">
{tx.description || t(`shops.incomeSettlement.${tx.type}Default`)}
</span>
<span className="text-xs text-neutral-500">
{new Date(tx.createdAt).toLocaleDateString("fa-IR")}
</span>
</div>
<span
className={cn(
"font-bold",
tx.type === "credit" ? "text-[#008D0E]" : "text-[#BA4141]"
)}
>
{tx.type === "credit" ? "+" : "-"}
{tx.amount.toLocaleString()} {t("settings.toman")}
</span>
</div>
))
)}
</div>
<Modal isOpen={showFilter} onClose={() => setShowFilter(false)} height="320px">
<div className="flex flex-col items-center gap-4">
<span className="font-semibold">{t("shops.salesHistory.dateRangeLabel")}</span>
<DatePicker
range
calendar={persian}
locale={persian_fa}
value={
[dateRange.startDate, dateRange.endDate].filter(
Boolean
) as unknown as DateObject[]
}
onChange={(value) => {
const arr = Array.isArray(value) ? value : value ? [value] : [];
setDateRange({
startDate: toIsoDate(arr[0] || null),
endDate: toIsoDate(arr[1] || null),
});
}}
calendarPosition="bottom-center"
placeholder={t("shops.salesHistory.dateRangePlaceholder")}
inputClass="w-full max-w-xs rounded-full border border-neutral-200 px-4 py-2 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900"
/>
<div className="flex w-full max-w-xs gap-2">
<RoundedButton
className="flex-1"
onClick={() => setDateRange({ startDate: null, endDate: null })}
>
{t("shops.salesHistory.clearFilter")}
</RoundedButton>
<RoundedButton
variant="primary"
className="flex-1"
onClick={() => setShowFilter(false)}
>
{t("shops.salesHistory.applyFilter")}
</RoundedButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useCallback, useEffect, useState } from "react";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import { useTranslation } from "react-i18next";
type SeriesPoint = { _id: string; revenue: number; orders: number };
type ShopAnalytics = {
range: string;
series: SeriesPoint[];
totals: { totalRevenue: number; totalOrders: number };
bestSellers: {
listingId: string;
title: string;
image: string | null;
quantity: number;
revenue: number;
}[];
rating: { average: number; count: number };
};
type WalletSummary = {
shop: { available: number; pending: number };
};
const RANGE_OPTIONS = ["7d", "30d", "90d", "all"] as const;
type RangeOption = (typeof RANGE_OPTIONS)[number];
export default function ShopPerformanceWidget() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [range, setRange] = useState<RangeOption>("30d");
const [data, setData] = useState<ShopAnalytics | null>(null);
const [summary, setSummary] = useState<WalletSummary | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
Promise.all([
request<ShopAnalytics>("GET", `/shops/analytics?range=${range}`, null, {
noToast: true,
}),
request<WalletSummary>("GET", "/wallet/summary", null, { noToast: true }),
])
.then(([analytics, walletSummary]) => {
setData(analytics || null);
setSummary(walletSummary || null);
})
.catch(() => {
setData(null);
setSummary(null);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range]);
useEffect(() => {
load();
}, [load]);
const totals = data?.totals;
const series = data?.series || [];
return (
<div className="mx-auto mt-6 w-full max-w-md">
<div className="rounded-2xl border border-neutral-200 p-4 text-center dark:border-neutral-800">
<p className="text-xs text-neutral-500">{t("shops.availableToWithdraw")}</p>
<p className="text-2xl font-bold">
{(summary?.shop.available || 0).toLocaleString()} {t("settings.toman")}
</p>
</div>
<div className="my-4 flex justify-center gap-1 rounded-full bg-neutral-100 p-1 dark:bg-neutral-800">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
onClick={() => setRange(opt)}
className={`flex-1 rounded-full px-3 py-1.5 text-xs font-semibold transition ${
range === opt
? "bg-white text-neutral-900 shadow dark:bg-neutral-700 dark:text-white"
: "text-neutral-500"
}`}
>
{t(`settings.analytics.range.${opt}`)}
</button>
))}
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">
{(totals?.totalRevenue || 0).toLocaleString()}
</p>
<p className="text-[11px] text-neutral-500">
{t("shops.performance.totalRevenue")}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">{totals?.totalOrders ?? 0}</p>
<p className="text-[11px] text-neutral-500">
{t("shops.performance.totalOrders")}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
<p className="text-lg font-bold">
{(data?.rating.average || 0).toFixed(1)}
</p>
<p className="text-[11px] text-neutral-500">
{t("shops.performance.rating", { count: data?.rating.count || 0 })}
</p>
</div>
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-3 text-sm font-bold">
{t("shops.performance.revenueChartTitle")}
</h3>
{loading ? (
<div className="h-48 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
) : series.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("shops.performance.empty")}
</p>
) : (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={series} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="shopRevenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0095f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#0095f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
<XAxis dataKey="_id" tick={{ fontSize: 9 }} />
<YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
<Tooltip />
<Area
type="monotone"
dataKey="revenue"
stroke="#0095f6"
strokeWidth={2}
fill="url(#shopRevenueGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="mt-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
<h3 className="mb-3 text-sm font-bold">
{t("shops.performance.bestSellersTitle")}
</h3>
{loading ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : !data?.bestSellers.length ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("shops.performance.bestSellersEmpty")}
</p>
) : (
<div className="flex flex-col gap-2">
{data.bestSellers.map((item) => (
<div
key={item.listingId}
className="flex items-center gap-3 rounded-xl border border-neutral-100 p-2 dark:border-neutral-800"
>
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-neutral-100 dark:bg-neutral-800">
{item.image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + item.image}
alt={item.title}
className="h-full w-full object-cover"
/>
)}
</span>
<span className="flex-1 truncate text-xs font-semibold">
{item.title}
</span>
<span className="shrink-0 text-xs text-neutral-500">
{t("shops.performance.soldCount", { count: item.quantity })}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import countriesData from "@/data/geo/countries.json";
import provincesData from "@/data/geo/provinces.json";
import citiesData from "@/data/geo/cities.json";
type Country = { id: number; iso2: string; name: string; name_fa: string; slug: string };
type Province = { id: number; name: string; slug: string; country_id: number };
type City = { id: number; name: string; slug: string; province_id: number; country_id: number };
const countries = countriesData as Country[];
const provinces = provincesData as Province[];
const cities = citiesData as City[];
export type GeoSelection = {
countryId: number | null;
provinceId: number | null;
cityId: number | null;
};
type CountryProvinceCitySelectProps = {
value: GeoSelection;
onChange: (value: GeoSelection) => void;
className?: string;
};
const selectClass =
"w-full rounded-full border border-neutral-200 bg-white px-4 py-2.5 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-900";
export default function CountryProvinceCitySelect({
value,
onChange,
className,
}: CountryProvinceCitySelectProps) {
const { i18n } = useTranslation("common");
const isFa = i18n.language === "fa";
const sortedCountries = useMemo(
() =>
[...countries].sort((a, b) =>
(isFa ? a.name_fa : a.name).localeCompare(isFa ? b.name_fa : b.name)
),
[isFa]
);
const availableProvinces = useMemo(
() =>
value.countryId
? provinces
.filter((p) => p.country_id === value.countryId)
.sort((a, b) => a.name.localeCompare(b.name))
: [],
[value.countryId]
);
const availableCities = useMemo(
() =>
value.provinceId
? cities
.filter((c) => c.province_id === value.provinceId)
.sort((a, b) => a.name.localeCompare(b.name))
: [],
[value.provinceId]
);
return (
<div className={className}>
<select
className={selectClass}
value={value.countryId ?? ""}
onChange={(e) => {
const countryId = e.target.value ? Number(e.target.value) : null;
onChange({ countryId, provinceId: null, cityId: null });
}}
>
<option value="">{isFa ? "انتخاب کشور" : "Select country"}</option>
{sortedCountries.map((c) => (
<option key={c.id} value={c.id}>
{isFa ? c.name_fa : c.name}
</option>
))}
</select>
{value.countryId && (
<select
className={`${selectClass} mt-2`}
value={value.provinceId ?? ""}
onChange={(e) => {
const provinceId = e.target.value ? Number(e.target.value) : null;
onChange({ ...value, provinceId, cityId: null });
}}
>
<option value="">{isFa ? "انتخاب استان" : "Select province"}</option>
{availableProvinces.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
{value.provinceId && (
<select
className={`${selectClass} mt-2`}
value={value.cityId ?? ""}
onChange={(e) => {
const cityId = e.target.value ? Number(e.target.value) : null;
onChange({ ...value, cityId });
}}
>
<option value="">{isFa ? "انتخاب شهر" : "Select city"}</option>
{availableCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
</div>
);
}
export function findCountry(countryId: number | null) {
return countries.find((c) => c.id === countryId) || null;
}
export function findProvince(provinceId: number | null) {
return provinces.find((p) => p.id === provinceId) || null;
}
export function findCity(cityId: number | null) {
return cities.find((c) => c.id === cityId) || null;
}

View File

@@ -78,6 +78,11 @@ export const editUserNavLinks = [
href: "/two-factor",
icon: "shield-tick.svg",
},
{
labelKey: "settings.edit.nav.linkDevice",
href: "/link-device",
icon: "shield-tick.svg",
},
{
labelKey: "settings.edit.nav.avatar",
href: "/avatar",
@@ -143,6 +148,11 @@ export const editUserNavLinks = [
href: "/archive",
icon: "archive-book.svg",
},
{
labelKey: "settings.edit.nav.analytics",
href: "/analytics",
icon: "chart-square.svg",
},
{
labelKey: "settings.edit.nav.activity",
href: "/activity",

73320
src/data/geo/cities.json Normal file

File diff suppressed because it is too large Load Diff

121
src/data/geo/countries.json Normal file
View File

@@ -0,0 +1,121 @@
[
{
"id": 1,
"iso2": "AF",
"name": "Afghanistan",
"name_fa": "افغانستان",
"slug": "af"
},
{
"id": 12,
"iso2": "AM",
"name": "Armenia",
"name_fa": "ارمنستان",
"slug": "am"
},
{
"id": 16,
"iso2": "AZ",
"name": "Azerbaijan",
"name_fa": "آذربایجان",
"slug": "az"
},
{
"id": 18,
"iso2": "BH",
"name": "Bahrain",
"name_fa": "بحرین",
"slug": "bh"
},
{
"id": 103,
"iso2": "IR",
"name": "Iran",
"name_fa": "ایران",
"slug": "ir"
},
{
"id": 104,
"iso2": "IQ",
"name": "Iraq",
"name_fa": "عراق",
"slug": "iq"
},
{
"id": 112,
"iso2": "KZ",
"name": "Kazakhstan",
"name_fa": "قزاقستان",
"slug": "kz"
},
{
"id": 117,
"iso2": "KW",
"name": "Kuwait",
"name_fa": "کویت",
"slug": "kw"
},
{
"id": 166,
"iso2": "OM",
"name": "Oman",
"name_fa": "عمان",
"slug": "om"
},
{
"id": 167,
"iso2": "PK",
"name": "Pakistan",
"name_fa": "پاکستان",
"slug": "pk"
},
{
"id": 179,
"iso2": "QA",
"name": "Qatar",
"name_fa": "قطر",
"slug": "qa"
},
{
"id": 182,
"iso2": "RU",
"name": "Russia",
"name_fa": "روسیه",
"slug": "ru"
},
{
"id": 194,
"iso2": "SA",
"name": "Saudi Arabia",
"name_fa": "عربستان",
"slug": "sa"
},
{
"id": 225,
"iso2": "TR",
"name": "Turkey",
"name_fa": "ترکیه",
"slug": "tr"
},
{
"id": 226,
"iso2": "TM",
"name": "Turkmenistan",
"name_fa": "ترکمنستان",
"slug": "tm"
},
{
"id": 231,
"iso2": "AE",
"name": "United Arab Emirates",
"name_fa": "امارات متحده عربی",
"slug": "ae"
},
{
"id": 236,
"iso2": "UZ",
"name": "Uzbekistan",
"name_fa": "ازبکستان",
"slug": "uz"
}
]

3033
src/data/geo/provinces.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,62 @@ export async function wrapRoomKeyForRecipient(
);
}
/**
* Wraps the identity private key (as JWK) for transfer to a new device during
* QR-based device linking — same ECIES-style pattern as wrapRoomKeyForRecipient,
* kept separate (own domain-separation label) so a bug in one never cross-affects
* the other. The server only ever sees this ciphertext blob.
*/
export async function wrapIdentityKeyForDevice(
privateJwk: JsonWebKey,
recipientEphemeralPublicKey: CryptoKey
): Promise<string> {
const ephemeral = await crypto.subtle.generateKey(ECDH_PARAMS, true, [
"deriveBits",
]);
const aesKey = await deriveAesKey(
ephemeral.privateKey,
recipientEphemeralPublicKey,
"modstagram-device-link-v1"
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(privateJwk));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, plaintext);
const epk = await exportPublicKeySpkiB64(ephemeral.publicKey);
return bufToB64(
new TextEncoder().encode(
JSON.stringify({
epk,
iv: bufToB64(iv),
ct: bufToB64(ct),
})
)
);
}
export async function unwrapIdentityKeyForDevice(
wrappedB64: string,
myEphemeralPrivateKey: CryptoKey
): Promise<JsonWebKey> {
const parsed = JSON.parse(new TextDecoder().decode(b64ToBuf(wrappedB64))) as {
epk: string;
iv: string;
ct: string;
};
const epk = await importPublicKeySpkiB64(parsed.epk);
const aesKey = await deriveAesKey(
myEphemeralPrivateKey,
epk,
"modstagram-device-link-v1"
);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64ToBuf(parsed.iv) },
aesKey,
b64ToBuf(parsed.ct)
);
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
}
export async function unwrapRoomKey(
wrappedB64: string,
myPrivateKey: CryptoKey

128
src/lib/e2ee/deviceLink.ts Normal file
View File

@@ -0,0 +1,128 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
generateIdentityKeyPair,
exportPublicKeySpkiB64,
importPublicKeySpkiB64,
exportPrivateKeyJwk,
wrapIdentityKeyForDevice,
unwrapIdentityKeyForDevice,
} from "./crypto";
import { idbSet } from "./storage";
import { fetchPeerPublicKey } from "./keys";
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export type DeviceLinkStartResult = {
linkId: string;
ephemeralPrivateKey: CryptoKey;
};
/** New/unlinked device: generates a throwaway ECDH keypair and registers a link session for it. */
export async function startDeviceLinkSession(): Promise<DeviceLinkStartResult> {
const pair = await generateIdentityKeyPair();
const ephemeralPublicKey = await exportPublicKeySpkiB64(pair.publicKey);
const base = getApiBaseUrl();
const res = await fetch(`${base}/account/e2ee/device-link`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ ephemeralPublicKey }),
});
if (!res.ok) throw new Error("Failed to start device link session");
const data = (await res.json()) as { linkId: string };
return { linkId: data.linkId, ephemeralPrivateKey: pair.privateKey };
}
/** New device: polls until the already-linked device has approved and wrapped its identity key. */
export async function pollDeviceLinkSession(
linkId: string
): Promise<string | null> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}/poll`,
{ headers: authHeaders() }
);
if (!res.ok) return null;
const data = (await res.json()) as {
status: string;
wrappedIdentityKey?: string;
};
return data.status === "fulfilled" && data.wrappedIdentityKey
? data.wrappedIdentityKey
: null;
}
/** New device: unwraps the received identity key and adopts it as this device's own — same identity, no re-publish needed. */
export async function finishDeviceLink(
userId: string,
wrappedIdentityKey: string,
ephemeralPrivateKey: CryptoKey
): Promise<void> {
const privateJwk = await unwrapIdentityKeyForDevice(
wrappedIdentityKey,
ephemeralPrivateKey
);
const publicKeyB64 = await fetchPeerPublicKey(userId);
if (!publicKeyB64) {
throw new Error("Could not fetch this account's published public key");
}
await idbSet(`identity:${userId}`, {
userId,
privateJwk,
publicKeyB64,
});
}
export type PendingDeviceLink = {
ephemeralPublicKey: string;
status: "pending" | "fulfilled";
};
/** Already-linked device: fetches the new device's ephemeral public key for a scanned/opened link. */
export async function getDeviceLinkSession(
linkId: string
): Promise<PendingDeviceLink | null> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}`,
{ headers: authHeaders() }
);
if (!res.ok) return null;
return (await res.json()) as PendingDeviceLink;
}
/** Already-linked device: wraps its own identity private key for the new device and submits it. */
export async function approveDeviceLink(
linkId: string,
myPrivateKey: CryptoKey,
newDeviceEphemeralPublicKeyB64: string
): Promise<void> {
const privateJwk = await exportPrivateKeyJwk(myPrivateKey);
const recipientKey = await importPublicKeySpkiB64(
newDeviceEphemeralPublicKeyB64
);
const wrappedIdentityKey = await wrapIdentityKeyForDevice(
privateJwk,
recipientKey
);
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}/approve`,
{
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ wrappedIdentityKey }),
}
);
if (!res.ok) throw new Error("Failed to approve device link");
}

View File

@@ -41,17 +41,44 @@ export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
dateLocale: "en-US",
enabled: true,
},
// Example for future — set enabled: true after adding locale files:
// ar: {
// code: "ar",
// nativeLabel: "العربية",
// englishLabel: "Arabic",
// direction: "rtl",
// htmlLang: "ar",
// ogLocale: "ar_SA",
// dateLocale: "ar-SA",
// enabled: false,
// },
// Below: registered but not yet enabled — no locale files exist for these yet, so
// they render in the picker's "Other languages" section without being selectable.
// Set enabled: true once src/locales/{code}/common.json (+ seo.json) exist and are
// registered in src/lib/i18n/resources.ts.
ar: { code: "ar", nativeLabel: "العربية", englishLabel: "Arabic", direction: "rtl", htmlLang: "ar", ogLocale: "ar_SA", dateLocale: "ar-SA", enabled: false },
ku: { code: "ku", nativeLabel: "کوردی", englishLabel: "Kurdish", direction: "rtl", htmlLang: "ku", ogLocale: "ku_TR", dateLocale: "ku", enabled: false },
tr: { code: "tr", nativeLabel: "Türkçe", englishLabel: "Turkish", direction: "ltr", htmlLang: "tr", ogLocale: "tr_TR", dateLocale: "tr-TR", enabled: false },
az: { code: "az", nativeLabel: "Azərbaycan dili", englishLabel: "Azerbaijani", direction: "ltr", htmlLang: "az", ogLocale: "az_AZ", dateLocale: "az-AZ", enabled: false },
ru: { code: "ru", nativeLabel: "Русский", englishLabel: "Russian", direction: "ltr", htmlLang: "ru", ogLocale: "ru_RU", dateLocale: "ru-RU", enabled: false },
uk: { code: "uk", nativeLabel: "Українська", englishLabel: "Ukrainian", direction: "ltr", htmlLang: "uk", ogLocale: "uk_UA", dateLocale: "uk-UA", enabled: false },
es: { code: "es", nativeLabel: "Español", englishLabel: "Spanish", direction: "ltr", htmlLang: "es", ogLocale: "es_ES", dateLocale: "es-ES", enabled: false },
ro: { code: "ro", nativeLabel: "Română", englishLabel: "Romanian", direction: "ltr", htmlLang: "ro", ogLocale: "ro_RO", dateLocale: "ro-RO", enabled: false },
sk: { code: "sk", nativeLabel: "Slovenčina", englishLabel: "Slovak", direction: "ltr", htmlLang: "sk", ogLocale: "sk_SK", dateLocale: "sk-SK", enabled: false },
pl: { code: "pl", nativeLabel: "Polski", englishLabel: "Polish", direction: "ltr", htmlLang: "pl", ogLocale: "pl_PL", dateLocale: "pl-PL", enabled: false },
hu: { code: "hu", nativeLabel: "Magyar", englishLabel: "Hungarian", direction: "ltr", htmlLang: "hu", ogLocale: "hu_HU", dateLocale: "hu-HU", enabled: false },
hr: { code: "hr", nativeLabel: "Hrvatski", englishLabel: "Croatian", direction: "ltr", htmlLang: "hr", ogLocale: "hr_HR", dateLocale: "hr-HR", enabled: false },
sr: { code: "sr", nativeLabel: "Српски", englishLabel: "Serbian", direction: "ltr", htmlLang: "sr", ogLocale: "sr_RS", dateLocale: "sr-RS", enabled: false },
el: { code: "el", nativeLabel: "Ελληνικά", englishLabel: "Greek", direction: "ltr", htmlLang: "el", ogLocale: "el_GR", dateLocale: "el-GR", enabled: false },
sv: { code: "sv", nativeLabel: "Svenska", englishLabel: "Swedish", direction: "ltr", htmlLang: "sv", ogLocale: "sv_SE", dateLocale: "sv-SE", enabled: false },
de: { code: "de", nativeLabel: "Deutsch", englishLabel: "German", direction: "ltr", htmlLang: "de", ogLocale: "de_DE", dateLocale: "de-DE", enabled: false },
it: { code: "it", nativeLabel: "Italiano", englishLabel: "Italian", direction: "ltr", htmlLang: "it", ogLocale: "it_IT", dateLocale: "it-IT", enabled: false },
hi: { code: "hi", nativeLabel: "हिन्दी", englishLabel: "Hindi", direction: "ltr", htmlLang: "hi", ogLocale: "hi_IN", dateLocale: "hi-IN", enabled: false },
bn: { code: "bn", nativeLabel: "বাংলা", englishLabel: "Bengali", direction: "ltr", htmlLang: "bn", ogLocale: "bn_BD", dateLocale: "bn-BD", enabled: false },
th: { code: "th", nativeLabel: "ไทย", englishLabel: "Thai", direction: "ltr", htmlLang: "th", ogLocale: "th_TH", dateLocale: "th-TH", enabled: false },
ms: { code: "ms", nativeLabel: "Bahasa Melayu", englishLabel: "Malay", direction: "ltr", htmlLang: "ms", ogLocale: "ms_MY", dateLocale: "ms-MY", enabled: false },
fil: { code: "fil", nativeLabel: "Filipino", englishLabel: "Filipino", direction: "ltr", htmlLang: "fil", ogLocale: "fil_PH", dateLocale: "fil-PH", enabled: false },
fr: { code: "fr", nativeLabel: "Français", englishLabel: "French", direction: "ltr", htmlLang: "fr", ogLocale: "fr_FR", dateLocale: "fr-FR", enabled: false },
nl: { code: "nl", nativeLabel: "Nederlands", englishLabel: "Dutch", direction: "ltr", htmlLang: "nl", ogLocale: "nl_NL", dateLocale: "nl-NL", enabled: false },
no: { code: "no", nativeLabel: "Norsk", englishLabel: "Norwegian", direction: "ltr", htmlLang: "no", ogLocale: "nb_NO", dateLocale: "nb-NO", enabled: false },
fi: { code: "fi", nativeLabel: "Suomi", englishLabel: "Finnish", direction: "ltr", htmlLang: "fi", ogLocale: "fi_FI", dateLocale: "fi-FI", enabled: false },
ps: { code: "ps", nativeLabel: "پښتو", englishLabel: "Pashto", direction: "rtl", htmlLang: "ps", ogLocale: "ps_AF", dateLocale: "ps-AF", enabled: false },
uz: { code: "uz", nativeLabel: "O'zbekcha", englishLabel: "Uzbek", direction: "ltr", htmlLang: "uz", ogLocale: "uz_UZ", dateLocale: "uz-UZ", enabled: false },
hy: { code: "hy", nativeLabel: "Հայերեն", englishLabel: "Armenian", direction: "ltr", htmlLang: "hy", ogLocale: "hy_AM", dateLocale: "hy-AM", enabled: false },
"zh-CN": { code: "zh-CN", nativeLabel: "简体中文", englishLabel: "Chinese (Simplified)", direction: "ltr", htmlLang: "zh-CN", ogLocale: "zh_CN", dateLocale: "zh-CN", enabled: false },
"zh-TW": { code: "zh-TW", nativeLabel: "繁體中文(台灣)", englishLabel: "Chinese (Traditional, Taiwan)", direction: "ltr", htmlLang: "zh-TW", ogLocale: "zh_TW", dateLocale: "zh-TW", enabled: false },
"zh-HK": { code: "zh-HK", nativeLabel: "繁體中文(香港)", englishLabel: "Chinese (Traditional, Hong Kong)", direction: "ltr", htmlLang: "zh-HK", ogLocale: "zh_HK", dateLocale: "zh-HK", enabled: false },
ja: { code: "ja", nativeLabel: "日本語", englishLabel: "Japanese", direction: "ltr", htmlLang: "ja", ogLocale: "ja_JP", dateLocale: "ja-JP", enabled: false },
ko: { code: "ko", nativeLabel: "한국어", englishLabel: "Korean", direction: "ltr", htmlLang: "ko", ogLocale: "ko_KR", dateLocale: "ko-KR", enabled: false },
};
export type AppLanguage = keyof typeof LANGUAGE_REGISTRY;

View File

@@ -15,10 +15,11 @@ export type LocaleNamespace = (typeof LOCALE_NAMESPACES)[number];
/**
* Locale bundles — add a new language by importing its JSON files here.
* Partial: most registry entries are `enabled: false` placeholders with no
* locale files yet (see registry.ts) — only enabled languages need a bundle.
*/
export const LOCALE_BUNDLES: Record<
AppLanguage,
Record<LocaleNamespace, Record<string, unknown>>
export const LOCALE_BUNDLES: Partial<
Record<AppLanguage, Record<LocaleNamespace, Record<string, unknown>>>
> = {
fa: {
common: faCommon,

View File

@@ -4,7 +4,10 @@
"description": "App display language",
"fa": "فارسی",
"en": "English",
"saved": "Language saved"
"saved": "Language saved",
"otherLanguages": "Other languages",
"comingSoon": "This language's translation isn't ready yet",
"comingSoonBadge": "Coming soon"
},
"common": {
"save": "Saved",
@@ -68,7 +71,15 @@
"notificationBody": "{{service}} was booked for {{date}} at {{time}}",
"statusPendingPayment": "Awaiting payment",
"statusConfirmed": "Confirmed",
"statusCancelled": "Cancelled"
"statusCancelled": "Cancelled",
"myBookingListingsTitle": "My online bookings",
"searchBookingListingPlaceholder": "Search bookings",
"myListingsSwitcher": "My online bookings",
"cancelAction": "Cancel booking",
"cancelConfirmHint": "Do you want to cancel this booking?",
"cancelSuccess": "Booking cancelled",
"cancelError": "Could not cancel booking",
"alreadyCancelled": "This booking has already been cancelled"
},
"verificationBadge": {
"licenseAlt": "License badge",
@@ -355,6 +366,8 @@
"googleAccount": "Google account",
"instagramImport": "Import from Instagram",
"twoFactor": "Two-factor login",
"linkDevice": "Link device (chat encryption)",
"analytics": "Analytics",
"avatar": "Profile photo",
"authentication": "Verification",
"expertise": "Expertise",
@@ -442,6 +455,84 @@
"enableButton": "Confirm and enable",
"disableButton": "Disable"
},
"linkDevice": {
"scanHint": "Scan this code with the camera of another device already signed in to this account, so encrypted chats become readable there too.",
"waiting": "Waiting for approval from the other device...",
"linking": "Transferring key...",
"copyLink": "Copy link (instead of scanning)",
"linkCopied": "Link copied",
"copyFailed": "Could not copy link",
"linkSuccess": "Device linked successfully",
"startError": "Could not create a link code",
"retry": "Try again",
"expired": "Link code expired",
"goToChats": "Go to messages",
"approveTitle": "Link a new device",
"approveHint": "A new device wants to link to this account's encryption. If you initiated this yourself, confirm below.",
"approveButton": "Confirm and link",
"approveSuccess": "New device linked",
"approveError": "Could not link device",
"invalidLink": "This link is invalid or has expired",
"cancel": "Cancel"
},
"region": {
"title": "Region",
"hint": "Select your country, province, and city",
"selectedLabel": "Selected country: {{country}}",
"saveSuccess": "Region saved"
},
"analyticsHub": {
"comingSoon": "Coming soon",
"sections": {
"posts": "Posts",
"shop": "Shop",
"academy": "Academy",
"billboards": "Billboards",
"projects": "Projects",
"booking": "Online booking"
},
"metrics": {
"totalRevenue": "Total revenue (Toman)",
"totalCount": "Total count",
"totalViews": "Total views",
"totalLikes": "Total likes",
"totalComments": "Total comments",
"rating_one": "Rating ({{count}} review)",
"rating_other": "Rating ({{count}} reviews)",
"revenueChartTitle": "Revenue trend",
"empty": "No data to display",
"topItemsTitle": "Top performers"
},
"bookingStatus": {
"pending_payment": "Awaiting payment",
"confirmed": "Confirmed",
"cancelled": "Cancelled"
}
},
"postsAnalytics": {
"listTitle": "Your posts",
"empty": "You haven't posted anything yet",
"noCaption": "No caption",
"viewsShort": "{{count}} views",
"likesShort": "{{count}} likes",
"detailTitle": "Post analytics",
"notFound": "Could not access this post's stats",
"views": "Views",
"likes": "Likes",
"comments": "Comments",
"shares": "Shares",
"saves": "Saves",
"sends": "Sends",
"newFollows": "New follows",
"offers": "Offers",
"watchMinutes": "Watch minutes",
"audienceTitle": "Audience",
"followers": "Followers",
"nonFollowers": "Non-followers",
"geographyTitle": "Views by province",
"geographyHint": "Based on the profile province of {{count}} viewers who made their location public — not their live location at view time",
"geographyEmpty": "Not enough location data yet"
},
"expertise": {
"question": "What is your area of expertise?",
"updated": "Expertise updated successfully",
@@ -2500,6 +2591,62 @@
"buyerTab": "Buyer",
"sellerOrdersEmpty": "No orders for your shop yet",
"buyerOrdersEmpty": "You haven't purchased anything yet",
"sellerTabs": {
"orders": "Orders",
"income": "Income & Settlement",
"salesHistory": "Sales History",
"performance": "Shop Performance"
},
"buyerTabs": {
"orders": "Orders",
"cart": "Cart",
"favorites": "Favorites",
"purchaseHistory": "Purchase History"
},
"cart": {
"empty": "Your cart is empty",
"unavailable": "This item is no longer available",
"remove": "Remove",
"total": "Total"
},
"favoritesTab": {
"empty": "You haven't added any items to favorites yet",
"remove": "Remove from favorites"
},
"purchaseHistory": {
"title": "Purchase History",
"empty": "No purchases found"
},
"incomeSettlement": {
"title": "Income & Settlement",
"receipts": "Receipts",
"payments": "Payments",
"empty": "No transactions found",
"creditDefault": "Wallet deposit",
"debitDefault": "Wallet withdrawal"
},
"salesHistory": {
"title": "Sales History",
"empty": "No orders found",
"filterTitle": "Filter orders",
"dateRangeLabel": "Date range",
"dateRangePlaceholder": "Select date range",
"statusLabel": "Status",
"clearFilter": "Clear filter",
"applyFilter": "Apply filter"
},
"performance": {
"totalRevenue": "Total revenue (Toman)",
"totalOrders": "Total orders",
"rating_one": "Rating ({{count}} review)",
"rating_other": "Rating ({{count}} reviews)",
"revenueChartTitle": "Revenue trend",
"empty": "No data to display",
"bestSellersTitle": "Product performance",
"bestSellersEmpty": "No sales yet",
"soldCount_one": "{{count}} sold",
"soldCount_other": "{{count}} sold"
},
"status": {
"draft": "Draft",
"pending_review": "Pending review",
@@ -2545,6 +2692,7 @@
"noProductsYet": "No products yet",
"taggedPostBadge": "Post",
"priceLabel": "Price: {{amount}} Toman",
"discountPercent": "{{percent}}%",
"addProduct": "Add product",
"comparisonTitle": "Compare shop prices",
"noListingsForProduct": "No shops found for this product",

View File

@@ -4,7 +4,10 @@
"description": "زبان نمایش رابط کاربری",
"fa": "فارسی",
"en": "English",
"saved": "زبان ذخیره شد"
"saved": "زبان ذخیره شد",
"otherLanguages": "دیگر زبان‌ها",
"comingSoon": "ترجمه‌ی این زبان هنوز آماده نشده است",
"comingSoonBadge": "به‌زودی"
},
"common": {
"save": "ذخیره شد",
@@ -68,7 +71,15 @@
"notificationBody": "خدمت {{service}} در تاریخ {{date}} ساعت {{time}} رزرو شد",
"statusPendingPayment": "در انتظار پرداخت",
"statusConfirmed": "تایید شده",
"statusCancelled": "لغو شده"
"statusCancelled": "لغو شده",
"myBookingListingsTitle": "رزروهای آنلاین من",
"searchBookingListingPlaceholder": "جستجوی رزرو",
"myListingsSwitcher": "رزروهای آنلاین من",
"cancelAction": "لغو رزرو",
"cancelConfirmHint": "می‌خواهید این رزرو را لغو کنید؟",
"cancelSuccess": "رزرو لغو شد",
"cancelError": "خطا در لغو رزرو",
"alreadyCancelled": "این رزرو قبلاً لغو شده است"
},
"verificationBadge": {
"licenseAlt": "تیک مجوز",
@@ -355,6 +366,8 @@
"googleAccount": "حساب گوگل",
"instagramImport": "وارد کردن از اینستاگرام",
"twoFactor": "ورود دو مرحله‌ای",
"linkDevice": "اتصال دستگاه (رمزنگاری چت)",
"analytics": "آنالیتیکس",
"avatar": "تصویر پروفایل",
"authentication": "احراز هویت",
"expertise": "تخصص",
@@ -442,6 +455,84 @@
"enableButton": "تایید و فعال‌سازی",
"disableButton": "غیرفعال کردن"
},
"linkDevice": {
"scanHint": "این کد را با دوربین دستگاه دیگری که قبلاً وارد همین حساب شده اسکن کنید تا چت‌های رمزنگاری‌شده روی این دستگاه هم قابل‌خواندن شوند.",
"waiting": "در انتظار تایید از دستگاه دیگر...",
"linking": "در حال انتقال کلید...",
"copyLink": "کپی لینک (به‌جای اسکن)",
"linkCopied": "لینک کپی شد",
"copyFailed": "کپی لینک انجام نشد",
"linkSuccess": "دستگاه با موفقیت متصل شد",
"startError": "خطا در ساخت کد اتصال",
"retry": "تلاش دوباره",
"expired": "کد اتصال منقضی شد",
"goToChats": "رفتن به پیام‌ها",
"approveTitle": "اتصال دستگاه جدید",
"approveHint": "یک دستگاه جدید می‌خواهد به رمزنگاری این حساب متصل شود. اگر خودتان این درخواست را داده‌اید، تایید کنید.",
"approveButton": "تایید و اتصال",
"approveSuccess": "دستگاه جدید متصل شد",
"approveError": "خطا در اتصال دستگاه",
"invalidLink": "این لینک نامعتبر یا منقضی شده است",
"cancel": "انصراف"
},
"region": {
"title": "منطقه",
"hint": "کشور، استان و شهر خودتون رو انتخاب کنید",
"selectedLabel": "کشور انتخاب‌شده: {{country}}",
"saveSuccess": "منطقه ذخیره شد"
},
"analyticsHub": {
"comingSoon": "به‌زودی",
"sections": {
"posts": "پست‌ها",
"shop": "فروشگاه",
"academy": "آموزشگاه",
"billboards": "بیلبورد",
"projects": "پروژه‌ها",
"booking": "رزرو آنلاین"
},
"metrics": {
"totalRevenue": "درآمد کل (تومان)",
"totalCount": "تعداد کل",
"totalViews": "بازدید کل",
"totalLikes": "لایک کل",
"totalComments": "کامنت کل",
"rating_one": "امتیاز ({{count}} نظر)",
"rating_other": "امتیاز ({{count}} نظر)",
"revenueChartTitle": "روند درآمد",
"empty": "داده‌ای برای نمایش نیست",
"topItemsTitle": "پرمخاطب‌ترین‌ها"
},
"bookingStatus": {
"pending_payment": "در انتظار پرداخت",
"confirmed": "تایید شده",
"cancelled": "لغو شده"
}
},
"postsAnalytics": {
"listTitle": "پست‌های شما",
"empty": "هنوز پستی ثبت نکرده‌اید",
"noCaption": "بدون متن",
"viewsShort": "{{count}} بازدید",
"likesShort": "{{count}} لایک",
"detailTitle": "آنالیتیکس پست",
"notFound": "دسترسی به آمار این پست ممکن نیست",
"views": "بازدید",
"likes": "لایک",
"comments": "کامنت",
"shares": "اشتراک‌گذاری",
"saves": "ذخیره",
"sends": "ارسال",
"newFollows": "فالوور جدید",
"offers": "درخواست همکاری",
"watchMinutes": "دقیقه تماشا",
"audienceTitle": "مخاطبان",
"followers": "فالوورها",
"nonFollowers": "غیرفالوورها",
"geographyTitle": "بازدید به تفکیک استان",
"geographyHint": "بر اساس استان ثبت‌شده در پروفایل {{count}} بازدیدکننده‌ای که موقعیت مکانی‌شان را عمومی کرده‌اند — نه موقعیت لحظه‌ای بازدید",
"geographyEmpty": "هنوز داده‌ی موقعیت مکانی کافی نیست"
},
"expertise": {
"question": "در چه زمینه ای تخصص دارید؟",
"updated": "تخصص با موفقیت به‌روزرسانی شد",
@@ -2499,6 +2590,62 @@
"buyerTab": "خریدار",
"sellerOrdersEmpty": "هنوز سفارشی برای فروشگاه شما ثبت نشده است",
"buyerOrdersEmpty": "هنوز خریدی ثبت نکرده‌اید",
"sellerTabs": {
"orders": "سفارش‌ها",
"income": "درآمد و تسویه",
"salesHistory": "تاریخچه فروش",
"performance": "عملکرد فروشگاه"
},
"buyerTabs": {
"orders": "سفارش‌ها",
"cart": "سبد خرید",
"favorites": "علاقمندی‌ها",
"purchaseHistory": "تاریخچه خرید"
},
"cart": {
"empty": "سبد خرید شما خالی است",
"unavailable": "این کالا دیگر موجود نیست",
"remove": "حذف",
"total": "جمع کل"
},
"favoritesTab": {
"empty": "هنوز کالایی به علاقمندی‌ها اضافه نکرده‌اید",
"remove": "حذف از علاقمندی‌ها"
},
"purchaseHistory": {
"title": "تاریخچه خرید",
"empty": "خریدی یافت نشد"
},
"incomeSettlement": {
"title": "درآمد و تسویه",
"receipts": "دریافت",
"payments": "پرداخت",
"empty": "تراکنشی یافت نشد",
"creditDefault": "واریز به کیف پول",
"debitDefault": "برداشت از کیف پول"
},
"salesHistory": {
"title": "تاریخچه فروش",
"empty": "سفارشی یافت نشد",
"filterTitle": "فیلتر سفارش‌ها",
"dateRangeLabel": "بازه تاریخ",
"dateRangePlaceholder": "انتخاب بازه تاریخ",
"statusLabel": "وضعیت",
"clearFilter": "پاک کردن فیلتر",
"applyFilter": "اعمال فیلتر"
},
"performance": {
"totalRevenue": "درآمد کل (تومان)",
"totalOrders": "تعداد سفارش",
"rating_one": "امتیاز ({{count}} نظر)",
"rating_other": "امتیاز ({{count}} نظر)",
"revenueChartTitle": "روند درآمد",
"empty": "داده‌ای برای نمایش نیست",
"bestSellersTitle": "عملکرد کالاها",
"bestSellersEmpty": "هنوز فروشی ثبت نشده است",
"soldCount_one": "{{count}} فروش",
"soldCount_other": "{{count}} فروش"
},
"status": {
"draft": "پیش‌نویس",
"pending_review": "در انتظار بررسی",
@@ -2544,6 +2691,7 @@
"noProductsYet": "هنوز کالایی ثبت نشده است",
"taggedPostBadge": "پست",
"priceLabel": "قیمت: {{amount}} تومان",
"discountPercent": "{{percent}}٪",
"addProduct": "افزودن کالا",
"comparisonTitle": "مقایسه قیمت فروشگاه‌ها",
"noListingsForProduct": "فروشگاهی برای این کالا یافت نشد",