This commit is contained in:
payacom
2026-07-31 21:20:13 +03:30
parent 67f59eefdf
commit 5ecbcd1f26
50 changed files with 4793 additions and 116 deletions

View File

@@ -13,7 +13,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { GoogleSignInButton, InstagramSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
@@ -93,6 +93,7 @@ function Login() {
</AuthButton>
</form>
<GoogleSignInButton mode="login" />
<InstagramSignInButton mode="login" />
<Link href={usernameLoginHref}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
{t("auth.loginWithUsername")}

View File

@@ -11,7 +11,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { GoogleSignInButton, InstagramSignInButton } from "@/app/(auth)/AuthProviders";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { getAxiosErrorMessage } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
@@ -127,6 +127,7 @@ function Register() {
</AuthButton>
</form>
<GoogleSignInButton mode="register" />
<InstagramSignInButton mode="register" />
<Link href="/login">
<small className="text-[#292D32] dark:text-neutral-300 font-bold text-xs mt-6 block text-center">
{t("auth.haveAccount")}{" "}

View File

@@ -32,6 +32,11 @@ function UsernamePage() {
const [suggestions, setSuggestions] = useState<string[]>([]);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const instagramSuggestedUsername =
typeof window !== "undefined" &&
localStorage.getItem("auth_provider") === "instagram"
? sanitizeUsernameInput(localStorage.getItem("instagram_username") || "")
: "";
const schema = useMemo(
() =>
@@ -55,7 +60,7 @@ function UsernamePage() {
const formik = useFormik({
initialValues: {
username: "",
username: instagramSuggestedUsername,
},
validationSchema: schema,
onSubmit: async (values) => {
@@ -74,7 +79,9 @@ function UsernamePage() {
? localStorage.getItem("auth_provider")
: null;
router.push(
authProvider === "google" ? "/register/fullname" : "/register/password"
authProvider === "google" || authProvider === "instagram"
? "/register/fullname"
: "/register/password"
);
} catch (err: any) {
const data = err?.response?.data;
@@ -136,6 +143,14 @@ function UsernamePage() {
onBlur={formik.handleBlur}
/>
{!isDuplicate &&
instagramSuggestedUsername &&
formik.values.username === instagramSuggestedUsername && (
<small className="text-gray-400 mt-2 block text-center text-xs">
{t("auth.usernameSuggestedFromInstagram")}
</small>
)}
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
{t("auth.usernameDuplicate")}

View File

@@ -3,16 +3,23 @@
import GoogleSignInButtonBase, {
GoogleAuthRootProvider,
} from "@/components/auth/GoogleSignInButton";
import InstagramSignInButtonBase from "@/components/auth/InstagramSignInButton";
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
import { GOOGLE_OAUTH_CLIENT_ID } from "@/config/googleAuth";
import { INSTAGRAM_OAUTH_CLIENT_ID } from "@/config/instagramAuth";
import { createContext, useContext, useEffect, useState } from "react";
const GoogleClientIdContext = createContext("");
const InstagramClientIdContext = createContext("");
export function useGoogleClientId(): string {
return useContext(GoogleClientIdContext);
}
export function useInstagramClientId(): string {
return useContext(InstagramClientIdContext);
}
export function GoogleSignInButton(
props: Omit<React.ComponentProps<typeof GoogleSignInButtonBase>, "clientId">
) {
@@ -25,30 +32,53 @@ export function GoogleSignInButton(
);
}
export function InstagramSignInButton(
props: Omit<React.ComponentProps<typeof InstagramSignInButtonBase>, "clientId">
) {
const clientId = useInstagramClientId();
return (
<InstagramSignInButtonBase
{...props}
clientId={clientId || INSTAGRAM_OAUTH_CLIENT_ID}
/>
);
}
export default function AuthProviders({
children,
googleClientId = "",
instagramClientId = "",
}: {
children: React.ReactNode;
googleClientId?: string;
instagramClientId?: string;
}) {
const [resolvedClientId, setResolvedClientId] = useState(
googleClientId || GOOGLE_OAUTH_CLIENT_ID
);
const [resolvedInstagramClientId, setResolvedInstagramClientId] = useState(
instagramClientId || INSTAGRAM_OAUTH_CLIENT_ID
);
useEffect(() => {
let cancelled = false;
async function loadClientId() {
const fallback = googleClientId || GOOGLE_OAUTH_CLIENT_ID;
const instagramFallback = instagramClientId || INSTAGRAM_OAUTH_CLIENT_ID;
setResolvedClientId(fallback);
setResolvedInstagramClientId(instagramFallback);
try {
const res = await fetch("/api/auth/config", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as { googleClientId?: string };
const data = (await res.json()) as {
googleClientId?: string;
instagramClientId?: string;
};
if (!cancelled) {
setResolvedClientId(data?.googleClientId || fallback);
setResolvedInstagramClientId(data?.instagramClientId || instagramFallback);
}
} catch {
// keep fallback
@@ -60,13 +90,15 @@ export default function AuthProviders({
return () => {
cancelled = true;
};
}, [googleClientId]);
}, [googleClientId, instagramClientId]);
return (
<GoogleClientIdContext.Provider value={resolvedClientId}>
<GoogleAuthRootProvider clientId={resolvedClientId}>
<RegisterRouteGuard>{children}</RegisterRouteGuard>
</GoogleAuthRootProvider>
<InstagramClientIdContext.Provider value={resolvedInstagramClientId}>
<GoogleAuthRootProvider clientId={resolvedClientId}>
<RegisterRouteGuard>{children}</RegisterRouteGuard>
</GoogleAuthRootProvider>
</InstagramClientIdContext.Provider>
</GoogleClientIdContext.Provider>
);
}

View File

@@ -1,4 +1,5 @@
import { getGoogleClientId } from "@/lib/auth/googleClientId";
import { getInstagramClientId } from "@/lib/auth/instagramClientId";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -6,5 +7,6 @@ export const runtime = "nodejs";
export async function GET() {
return Response.json({
googleClientId: getGoogleClientId(),
instagramClientId: getInstagramClientId(),
});
}

View File

@@ -0,0 +1,142 @@
"use client";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import {
completeLogin,
getAxiosErrorMessage,
getLoginFailureMessage,
parseLoginResponse,
} from "@/lib/auth/postLogin";
import type { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type InstagramCallbackResponse = IVerifyOtp & {
instagram_username?: string | null;
instagram_bio?: string | null;
instagram_profile_picture_url?: string | null;
};
export default function InstagramCallbackPage() {
const { t, i18n } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [status, setStatus] = useState<"working" | "error">("working");
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
void handleCallback();
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const oauthError = params.get("error");
const stateRaw = params.get("state");
const storedMode = sessionStorage.getItem("ig_oauth_mode") || "login";
const storedNonce = sessionStorage.getItem("ig_oauth_nonce") || "";
sessionStorage.removeItem("ig_oauth_mode");
sessionStorage.removeItem("ig_oauth_nonce");
if (oauthError) {
toast.error(t("auth.instagramLoginFailed"));
setStatus("error");
router.replace(storedMode === "link" ? "/settings/edit/instagram-import" : "/login");
return;
}
let mode = storedMode;
try {
const parsedState = stateRaw
? (JSON.parse(decodeURIComponent(stateRaw)) as { mode?: string; nonce?: string })
: null;
if (parsedState?.mode) mode = parsedState.mode;
if (!parsedState?.nonce || parsedState.nonce !== storedNonce) {
throw new Error("nonce mismatch");
}
} catch {
toast.error(t("auth.instagramLoginFailed"));
setStatus("error");
router.replace("/login");
return;
}
if (!code) {
toast.error(t("auth.instagramLoginFailed"));
setStatus("error");
router.replace(mode === "link" ? "/settings/edit/instagram-import" : "/login");
return;
}
try {
if (mode === "link") {
await request("POST", "/auth/instagram/link", { code });
toast.success(t("settings.edit.instagramImport.linkSuccess"));
router.replace("/settings/edit/instagram-import");
return;
}
const rawResponse = await request("POST", "/auth/instagram/callback", { code });
const authResponse = parseLoginResponse(rawResponse) as InstagramCallbackResponse;
const raw = rawResponse as Partial<InstagramCallbackResponse>;
localStorage.setItem("auth_provider", "instagram");
localStorage.removeItem("mobile");
localStorage.removeItem("otp");
if (raw?.instagram_username) {
localStorage.setItem("instagram_username", raw.instagram_username);
}
if (raw?.instagram_bio) {
localStorage.setItem("instagram_bio", raw.instagram_bio);
}
if (raw?.instagram_profile_picture_url) {
localStorage.setItem(
"instagram_profile_picture_url",
raw.instagram_profile_picture_url
);
}
const loggedIn = await completeLogin(router, authResponse);
if (!loggedIn) {
const detail = getLoginFailureMessage(
rawResponse,
i18n.language === "en" ? "en" : "fa"
);
toast.error(detail);
setStatus("error");
router.replace("/login");
}
} catch (err: unknown) {
const data = (
err as { response?: { data?: { message?: string } } }
)?.response?.data;
const message =
data?.message ||
getAxiosErrorMessage(err, i18n.language === "en" ? "en" : "fa");
toast.error(message, { duration: 8000 });
setStatus("error");
router.replace(mode === "link" ? "/settings/edit/instagram-import" : "/login");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LocalePageShell>
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-neutral-500">
{status === "working"
? t("auth.instagramConnecting")
: t("auth.instagramLoginFailed")}
</p>
</div>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import InstagramSignInButton from "@/components/auth/InstagramSignInButton";
import useAxios from "@/hooks/useAxios";
import React, { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type InstagramStatus = {
linked: boolean;
connected: boolean;
instagram_username: string | null;
instagram_account_type: string | null;
connected_at: string | null;
};
export default function InstagramImportSettingsPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [status, setStatus] = useState<InstagramStatus | null>(null);
const [clientId, setClientId] = useState("");
const [loading, setLoading] = useState(true);
const [disconnecting, setDisconnecting] = useState(false);
const loadStatus = useCallback(async () => {
setLoading(true);
try {
const data = (await request("GET", "/auth/instagram/status")) as InstagramStatus;
setStatus(data);
} catch {
toast.error(t("settings.edit.instagramImport.loadError"));
} finally {
setLoading(false);
}
}, [request, t]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
useEffect(() => {
let cancelled = false;
fetch("/api/auth/config", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { instagramClientId?: string } | null) => {
if (!cancelled && data?.instagramClientId) {
setClientId(data.instagramClientId);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const handleDisconnect = async () => {
setDisconnecting(true);
try {
await request("POST", "/auth/instagram/disconnect");
toast.success(t("settings.edit.instagramImport.disconnectSuccess"));
await loadStatus();
} catch {
toast.error(t("settings.edit.instagramImport.disconnectError"));
} finally {
setDisconnecting(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.nav.instagramImport")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{loading ? (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
) : status?.connected ? (
<>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{t("settings.edit.instagramImport.linkedHint")}
</p>
{status.instagram_username ? (
<p className="mt-3 text-sm font-semibold" dir="ltr">
@{status.instagram_username}
</p>
) : null}
<button
type="button"
onClick={handleDisconnect}
disabled={disconnecting}
className="mt-6 rounded-2xl border-2 border-red-200 px-4 py-2 text-sm font-semibold text-red-600 disabled:opacity-60"
>
{t("settings.edit.instagramImport.disconnectButton")}
</button>
</>
) : (
<>
<p className="mb-6 text-sm text-neutral-600 dark:text-neutral-300">
{t("settings.edit.instagramImport.unlinkedHint")}
</p>
<InstagramSignInButton mode="link" clientId={clientId} />
</>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
"/username": "username",
"/password": "password",
"/google-account": "googleAccount",
"/instagram-import": "instagramImport",
"/two-factor": "twoFactor",
"/avatar": "avatar",
"/Authentication": "authentication",

View File

@@ -1,96 +1,264 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthInput from "@/components/auth/AuthInput";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
function ShabaPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
const schema = useMemo(
() =>
yup.object().shape({
shaba: yup
.string()
.matches(/^(?=.{24}$)[0-9]*$/, t("settings.edit.shaba.invalid"))
.required(t("settings.edit.shaba.required")),
}),
[t]
);
const formik = useFormik({
initialValues: { shaba: "" },
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("PATCH", "/verify/shaba", { shaba: values.shaba });
router.push("/settings/edit");
} catch (err: any) {
toast.error(err?.message || t("settings.edit.unknownError"));
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (user?.shaba) {
formik.setValues({ shaba: user.shaba });
}
}, [user?.shaba]);
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.shaba")}
</span>
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10">{t("settings.edit.shaba.forPayout")}</small>
<div className="flex flex-col w-full relative mt-4 ">
<span className="absolute left-4 top-1.5">IR</span>
<AuthInput
name="shaba"
type="text"
placeholder={t("settings.edit.shaba.placeholder")}
className={`border w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm ${
formik.touched.shaba && formik.errors.shaba
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.shaba}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.shaba && formik.errors.shaba && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.shaba}
</small>
)}
</div>
<small className="font-bold mt-10">{t("settings.edit.shaba.mustBeOwn")}</small>
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
{t("settings.edit.save")}
</AuthNextButton>
</form>
</div>
</Container>
</LocalePageShell>
);
}
export default ShabaPage;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import React, { useEffect, useMemo } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthInput from "@/components/auth/AuthInput";
import DatePicker from "react-multi-date-picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import Image from "next/image";
import moment from "jalali-moment";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
import {
getIranBankName,
isValidIranSheba,
stripShebaPrefix,
} from "@/lib/iranSheba";
import { isValidIranNationalCode } from "@/lib/iranNationalCode";
const p2e = (es: string): string =>
es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
function ShabaPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const user = useUser();
const schema = useMemo(
() =>
yup.object().shape({
nationalCode: yup
.string()
.matches(/^\d{10}$/, t("auth.nationalCodeInvalid"))
.test(
"checksum",
t("settings.edit.shaba.nationalCodeInvalid"),
(value) => !!value && isValidIranNationalCode(value)
)
.required(t("auth.nationalCodeRequired")),
birthDate: yup.string().required(t("auth.birthDateRequired")),
shaba: yup
.string()
.matches(/^\d{24}$/, t("settings.edit.shaba.invalid"))
.test(
"checksum",
t("settings.edit.shaba.invalid"),
(value) => !!value && isValidIranSheba(value)
)
.required(t("settings.edit.shaba.required")),
}),
[t]
);
const formik = useFormik({
initialValues: {
nationalCode: "",
birthDate: "",
shaba: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("POST", "/verify/auth", {
birthday: moment(p2e(values.birthDate), "jYYYY/jMM/jDD").format(
"YYYY/MM/DD"
),
national_code: values.nationalCode,
shaba: values.shaba,
});
toast.success(t("settings.edit.shaba.saveSuccess"));
router.push("/settings/edit");
} catch (err: any) {
const status = err?.response?.status;
const message =
status === 409
? t("settings.edit.shaba.nationalCodeDuplicate")
: err?.response?.data?.message || t("settings.edit.shaba.saveError");
toast.error(message);
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (!user) return;
const patch: Partial<typeof formik.values> = {};
if (user.shaba) patch.shaba = stripShebaPrefix(user.shaba);
if (user.national_code) patch.nationalCode = user.national_code;
if (user.birthday) {
patch.birthDate = moment(user.birthday, "YYYY/MM/DD").format(
"jYYYY/jMM/jDD"
);
}
if (Object.keys(patch).length > 0) {
formik.setValues((prev) => ({ ...prev, ...patch }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.shaba, user?.national_code, user?.birthday]);
const nationalCodeValid = isValidIranNationalCode(formik.values.nationalCode);
const shebaValid = isValidIranSheba(formik.values.shaba);
const bankName = shebaValid ? getIranBankName(formik.values.shaba) : null;
const isVerified = user?.is_verified;
const verifyDisabled = isVerified === "pending" || isVerified === "verified";
const verifyLabel =
isVerified === "verified"
? t("settings.edit.shaba.verifyIdentityDone")
: isVerified === "pending"
? t("settings.edit.shaba.verifyIdentityPending")
: t("settings.edit.shaba.verifyIdentityButton");
const canSave =
nationalCodeValid && shebaValid && Boolean(formik.values.birthDate) && !loading;
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center min-h-screen justify-center auth-page">
<span className="text-xl font-bold text-foreground">
{t("settings.edit.nav.shaba")}
</span>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<small className="font-bold mt-10 mb-4 text-center">
{t("settings.edit.shaba.forPayout")}
</small>
<div className="flex w-full gap-2 max-w-[300px]">
<div>
<AuthInput
name="nationalCode"
type="text"
inputMode="numeric"
maxLength={10}
placeholder={t("auth.nationalCodePlaceholder")}
className="!p-1 h-[36px] mt-2"
success={nationalCodeValid}
error={Boolean(
formik.touched.nationalCode && formik.errors.nationalCode
)}
value={formik.values.nationalCode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
</div>
<div className="relative">
<DatePicker
id="birthDate"
calendar={persian}
locale={persian_fa}
value={formik.values.birthDate}
onChange={(value) => {
formik.setFieldValue(
"birthDate",
value?.format("YYYY/MM/DD") || ""
);
}}
calendarPosition="bottom-center"
placeholder={t("auth.birthDatePlaceholder")}
inputClass={`w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border bg-secondary-light dark:bg-secondary-dark font-medium mt-2 !p-1 h-[36px] ${
formik.touched.birthDate && formik.errors.birthDate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
/>
<label htmlFor="birthDate" className="absolute right-4 top-3.5">
<Image
width={24}
height={24}
alt="calendar icon"
src="/images/icons/calendar.svg"
/>
</label>
</div>
</div>
{formik.touched.nationalCode && formik.errors.nationalCode && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.nationalCode}
</small>
)}
{formik.touched.birthDate && formik.errors.birthDate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.birthDate}
</small>
)}
<div className="flex flex-col w-full relative mt-8">
<span className="absolute left-4 top-1.5 z-10">IR</span>
<AuthInput
name="shaba"
type="text"
inputMode="numeric"
maxLength={24}
placeholder={t("settings.edit.shaba.placeholder")}
className="w-full mx-auto h-[36px] p-2 max-w-full pl-8 text-sm"
success={shebaValid}
error={Boolean(formik.touched.shaba && formik.errors.shaba)}
value={formik.values.shaba}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{bankName && (
<small className="mt-2 text-center text-xs font-semibold text-green-600 dark:text-green-400">
{bankName}
</small>
)}
{formik.touched.shaba && formik.errors.shaba && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.shaba}
</small>
)}
</div>
<small className="font-bold mt-10 text-center">
{t("settings.edit.shaba.mustBeOwn")}
</small>
<AuthNextButton
type="button"
className="mt-10"
disabled={verifyDisabled}
onClick={() => router.push("/settings/edit/Authentication")}
>
{verifyLabel}
</AuthNextButton>
{isVerified === "rejected" && (
<small className="text-red-500 mt-2 block text-center">
{t("settings.edit.shaba.verifyIdentityRejectedHint")}
</small>
)}
<AuthNextButton
type="submit"
variant="primary"
className="mt-5"
loading={loading}
disabled={!canSave}
>
{t("settings.edit.shaba.saveButton")}
</AuthNextButton>
</form>
</div>
</Container>
</LocalePageShell>
);
}
export default ShabaPage;

View File

@@ -35,6 +35,7 @@ export default function Financial() {
const getTransactionType = (type: string) => {
if (type === "offer") return t("settings.financial.types.offer");
if (type === "advertising") return t("settings.financial.types.advertising");
if (type === "shop_order") return t("settings.financial.types.shop_order");
return type || t("settings.financial.types.other");
};

View File

@@ -0,0 +1,191 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SellerOrderItem from "@/components/shops/orders/SellerOrderItem";
import BuyerOrderItem from "@/components/shops/orders/BuyerOrderItem";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const FILTER_SELLER = "seller";
const FILTER_BUYER = "buyer";
type MyShop = {
_id: string;
name: string;
logo?: string | null;
status: string;
};
export default function ShopSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [filter, setFilter] = useState<string>(FILTER_SELLER);
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [showSwitcher, setShowSwitcher] = useState(false);
const loadShops = () => {
request<{ shops: MyShop[] }>("GET", "/shops/mine")
.then((res) => setMyShops(res?.shops || []))
.catch(() => setMyShops([]));
};
useEffect(() => {
loadShops();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddShop = () => {
if (!myShops || myShops.length === 0) {
router.push("/shops/new/name");
return;
}
// Shop(s) already exist — go add a product to the most recent one.
router.push(`/shops/${myShops[0]._id}/products/new/name`);
};
const primaryShopId = myShops && myShops.length > 0 ? myShops[0]._id : null;
const { data: sellerOrdersData, isLoading: sellerOrdersLoading } = useInfiniteScroll({
endpoint: "/orders",
queryKey: ["seller-orders", primaryShopId || ""],
params: primaryShopId ? { role: "seller", shopId: primaryShopId } : undefined,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sellerOrders: any[] =
filter === FILTER_SELLER && primaryShopId
? sellerOrdersData?.pages.flatMap((page) => page.docs || []) || []
: [];
const { data: buyerOrdersData, isLoading: buyerOrdersLoading } = useInfiniteScroll({
endpoint: "/orders",
queryKey: ["buyer-orders"],
params: { role: "buyer" },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buyerOrders: any[] =
filter === FILTER_BUYER
? buyerOrdersData?.pages.flatMap((page) => page.docs || []) || []
: [];
return (
<LocalePageShell>
<Container>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setShowSwitcher((v) => !v)}
aria-label={t("shops.mySwitcher")}
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/shop-add.svg")}
className="dark:invert"
/>
</button>
<PageTitle>{t("settings.nav.shop")}</PageTitle>
<button type="button" onClick={handleAddShop} aria-label={t("shops.addShop")}>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:invert"
/>
</button>
</div>
{showSwitcher && (
<div className="mx-auto mt-4 max-w-md rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
{myShops === null ? (
<p className="text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : myShops.length === 0 ? (
<p className="text-center text-sm text-neutral-500">
{t("shops.noShopsYet")}
</p>
) : (
myShops.map((shop) => (
<div
key={shop._id}
className="flex items-center gap-2 border-b border-neutral-100 py-2 last:border-none dark:border-neutral-800"
>
{shop.logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shop.logo}
alt={shop.name}
className="h-8 w-8 rounded-lg object-cover"
/>
)}
<span className="text-sm font-semibold">{shop.name}</span>
<span className="text-xs text-neutral-400">
{t(`shops.status.${shop.status}`)}
</span>
</div>
))
)}
</div>
)}
<div className="text-xs md:text-sm">
<UserDetails />
<div className="mx-auto mt-8 grid w-full max-w-md grid-cols-2 gap-4">
<RoundedButton
className={cn(toggleBtnClass(filter === FILTER_SELLER), "h-9")}
onClick={() => setFilter(FILTER_SELLER)}
>
{t("shops.sellerTab")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(filter === FILTER_BUYER), "h-9")}
onClick={() => setFilter(FILTER_BUYER)}
>
{t("shops.buyerTab")}
</RoundedButton>
</div>
<div className="mx-auto mt-10 w-full max-w-md">
{filter === FILTER_SELLER ? (
sellerOrdersLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : sellerOrders.length === 0 ? (
<p className="text-center text-neutral-500">
{t("shops.sellerOrdersEmpty")}
</p>
) : (
sellerOrders.map((order) => (
<SellerOrderItem key={order._id} order={order} />
))
)
) : buyerOrdersLoading ? (
<p className="text-center text-neutral-500">{t("common.loading")}</p>
) : buyerOrders.length === 0 ? (
<p className="text-center text-neutral-500">
{t("shops.buyerOrdersEmpty")}
</p>
) : (
buyerOrders.map((order) => (
<BuyerOrderItem key={order._id} order={order} />
))
)}
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,218 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const BUCKET_SHOP = "shop";
const BUCKET_GIFT = "gift";
const BUCKET_CHARGED = "charged";
type WalletSummary = {
shop: { available: number; pending: number };
gift: { available: number; minWithdrawal: number };
charged: { available: number };
};
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
const [summary, setSummary] = useState<WalletSummary | null>(null);
const [withdrawAmount, setWithdrawAmount] = useState("");
const [chargeAmount, setChargeAmount] = useState("");
const loadSummary = () => {
request<WalletSummary>("GET", "/wallet/summary")
.then((res) => setSummary(res))
.catch(() => setSummary(null));
};
useEffect(() => {
loadSummary();
const charge = searchParams.get("charge");
if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const totalBalance =
(summary?.shop.available || 0) +
(summary?.gift.available || 0) +
(summary?.charged.available || 0);
const handleWithdraw = async () => {
const amount = Number(withdrawAmount);
if (!amount || amount <= 0) return;
try {
await request("POST", "/wallet/withdraw", { bucket, amount });
toast.success(t("shops.withdrawalRequested"));
setWithdrawAmount("");
loadSummary();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleCharge = async () => {
const amount = Number(chargeAmount);
if (!amount || amount <= 0) return;
try {
const response = await request<{ authority?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
}
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.nav.wallet")}</PageTitle>
<div className="mx-auto mt-4 max-w-md text-center">
<p className="text-xs text-neutral-500">{t("shops.totalBalance")}</p>
<p className="text-2xl font-bold">
{totalBalance.toLocaleString()} {t("settings.toman")}
</p>
</div>
<div className="mx-auto mt-6 grid w-full max-w-md grid-cols-3 gap-2">
<RoundedButton
className={cn(toggleBtnClass(bucket === BUCKET_SHOP), "h-9 text-xs")}
onClick={() => setBucket(BUCKET_SHOP)}
>
{t("shops.walletShopTab")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(bucket === BUCKET_GIFT), "h-9 text-xs")}
onClick={() => setBucket(BUCKET_GIFT)}
>
{t("shops.walletGiftTab")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(bucket === BUCKET_CHARGED), "h-9 text-xs")}
onClick={() => setBucket(BUCKET_CHARGED)}
>
{t("shops.walletChargedTab")}
</RoundedButton>
</div>
<div className="mx-auto mt-6 max-w-md text-sm">
{bucket === BUCKET_SHOP && summary && (
<>
<div className="flex justify-between">
<span>{t("shops.availableToWithdraw")}</span>
<span className="font-bold">
{summary.shop.available.toLocaleString()} {t("settings.toman")}
</span>
</div>
<div className="mt-1 flex justify-between text-neutral-500">
<span>{t("shops.pendingConfirmation")}</span>
<span>
{summary.shop.pending.toLocaleString()} {t("settings.toman")}
</span>
</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>
</>
)}
{bucket === BUCKET_GIFT && summary && (
<>
<div className="flex justify-between">
<span>{t("shops.giftBalance")}</span>
<span className="font-bold">
{summary.gift.available.toLocaleString()} {t("settings.toman")}
</span>
</div>
<p className="mt-1 text-xs text-neutral-500">
{t("shops.giftMinWithdrawalHint", {
amount: summary.gift.minWithdrawal.toLocaleString(),
})}
</p>
<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 || summary.gift.available < summary.gift.minWithdrawal}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
{bucket === BUCKET_CHARGED && summary && (
<>
<div className="flex justify-between">
<span>{t("shops.chargedBalance")}</span>
<span className="font-bold">
{summary.charged.available.toLocaleString()} {t("settings.toman")}
</span>
</div>
<p className="mt-1 text-xs text-neutral-500">{t("shops.chargeBonusHint")}</p>
<div className="mt-4 flex gap-2">
<RoundedInput
type="number"
inputMode="numeric"
placeholder={t("shops.chargeAmountPlaceholder")}
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
/>
<RoundedButton variant="primary" disabled={loading} onClick={handleCharge}>
{t("shops.chargeWallet")}
</RoundedButton>
</div>
</>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import ShopProductGrid from "@/components/shops/ShopProductGrid";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type ShopHeader = {
_id: string;
name: string;
logo?: string | null;
status: string;
};
export default function ShopProductsPage() {
const { t } = useTranslation("common");
const params = useParams<{ shopId: string }>();
const shopId = params.shopId;
const { request } = useAxios();
const [shop, setShop] = useState<ShopHeader | null>(null);
useEffect(() => {
request<{ shop: ShopHeader }>("GET", `/shops/${shopId}`)
.then((res) => setShop(res?.shop || null))
.catch(() => setShop(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shopId]);
const { data, isLoading } = useInfiniteScroll({
endpoint: "/shop-products",
queryKey: ["shop-products", shopId],
params: { shopId },
});
const listings: IShopProductListing[] =
data?.pages.flatMap((page) => page.docs || []) || [];
return (
<LocalePageShell>
<Container>
<div className="flex items-center justify-between">
<PageTitle>{shop?.name || t("settings.nav.shop")}</PageTitle>
<Link
href={`/shops/${shopId}/products/new/name`}
aria-label={t("shops.addProduct")}
>
<Image
src="/images/icons/add.svg"
width={24}
height={24}
alt=""
className="dark:invert"
/>
</Link>
</div>
<ShopProductGrid
listings={listings}
shopName={shop?.name || ""}
shopLogo={shop?.logo}
isLoading={isLoading}
/>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useProductWizardId } from "@/hooks/useProductWizardId";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type PickedImage = { file: File; preview: string };
function ProductImagesPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ shopId: string }>();
const shopId = params.shopId;
const { request, loading } = useAxios();
const listingId = useProductWizardId(shopId);
const [images, setImages] = useState<PickedImage[]>([]);
const [primaryIndex, setPrimaryIndex] = useState(0);
const handleSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const picked = files.map((file) => ({
file,
preview: URL.createObjectURL(file),
}));
setImages((prev) => [...prev, ...picked]);
};
const removeImage = (index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
if (primaryIndex >= index && primaryIndex > 0) {
setPrimaryIndex((prev) => prev - 1);
}
};
const goNext = () => router.push(`/shops/${shopId}/products/new/variants`);
const handleSubmit = async () => {
if (!listingId) return;
if (images.length === 0) {
goNext();
return;
}
const formData = new FormData();
images.forEach((img) => formData.append("images", img.file, img.file.name));
formData.append("primaryImageIndex", String(primaryIndex));
try {
await request("PATCH", `/shop-products/${listingId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
goNext();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!listingId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productImagesTitle")}</span>
<p className="mb-4 text-xs text-neutral-500">
{t("shops.productImagesHint")}
</p>
<div className="grid w-full max-w-sm grid-cols-3 gap-2">
{images.map((img, index) => (
<div
key={img.preview}
className={cn(
"relative aspect-square overflow-hidden rounded-xl border-2",
primaryIndex === index
? "border-pink-500"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.preview}
alt=""
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => setPrimaryIndex(index)}
className={cn(
"absolute bottom-1 right-1 rounded-full px-2 py-0.5 text-[10px] font-bold",
primaryIndex === index
? "bg-pink-500 text-white"
: "bg-black/50 text-white"
)}
>
{primaryIndex === index
? t("shops.primaryImage")
: t("shops.setPrimaryImage")}
</button>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute top-1 left-1 rounded-full bg-black/50 p-1"
>
<Image
src="/images/icons/close-circle.svg"
width={16}
height={16}
alt=""
/>
</button>
</div>
))}
<label
htmlFor="productImagesInput"
className="flex aspect-square cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-neutral-300 dark:border-neutral-600"
>
<Image
src="/images/icons/gallery-add.svg"
width={36}
height={36}
alt=""
/>
</label>
<input
id="productImagesInput"
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleSelect}
/>
</div>
</AuthPageContent>
<AuthFormFooter onSkip={goNext} skipDisabled={loading}>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ProductImagesPage;

View File

@@ -0,0 +1,148 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthInput from "@/components/auth/AuthInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { saveProductWizardId } from "@/hooks/useProductWizardId";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type CatalogProduct = {
_id: string;
name: string;
description?: string | null;
};
function ProductNamePage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ shopId: string }>();
const shopId = params.shopId;
const { request, loading } = useAxios();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [catalogProductId, setCatalogProductId] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<CatalogProduct[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!name.trim()) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(() => {
request<{ products: CatalogProduct[] }>(
"GET",
`/shop-products/catalog/search?q=${encodeURIComponent(name.trim())}`
)
.then((res) => setSuggestions(res?.products || []))
.catch(() => setSuggestions([]));
}, 350);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name]);
const handleNameChange = (value: string) => {
setName(value);
setCatalogProductId(null);
};
const handlePick = (product: CatalogProduct) => {
setName(product.name);
setDescription(product.description || "");
setCatalogProductId(product._id);
setSuggestions([]);
};
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("shops.productNameRequired"));
return;
}
try {
const response = await request<{ listing: { _id: string } }>(
"POST",
"/shop-products",
{
shopId,
catalogProductId: catalogProductId || undefined,
name: name.trim(),
description: description.trim() || undefined,
}
);
if (response?.listing?._id) {
saveProductWizardId(shopId, response.listing._id);
}
router.push(`/shops/${shopId}/products/new/images`);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>
<div className="relative w-full max-w-sm">
<AuthInput
name="productName"
type="text"
placeholder={t("shops.productNamePlaceholder")}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
/>
{suggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-2xl border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
{suggestions.map((product) => (
<button
type="button"
key={product._id}
onClick={() => handlePick(product)}
className="block w-full px-4 py-2 text-right text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
{product.name}
</button>
))}
</div>
)}
</div>
<textarea
className="mt-4 h-28 w-full max-w-sm rounded-3xl border border-neutral-300 bg-white p-4 font-medium dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.productDescriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !name.trim()}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ProductNamePage;

View File

@@ -0,0 +1,194 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import {
clearProductWizardId,
useProductWizardId,
} from "@/hooks/useProductWizardId";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type VariantRow = {
color: string | null;
size: string | null;
weight: string | null;
price: string;
stock: string;
};
function parseOptions(value: string): string[] {
return value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
function buildCombinations(
colors: string[],
sizes: string[],
weights: string[]
): VariantRow[] {
const colorOptions = colors.length > 0 ? colors : [null];
const sizeOptions = sizes.length > 0 ? sizes : [null];
const weightOptions = weights.length > 0 ? weights : [null];
const rows: VariantRow[] = [];
for (const color of colorOptions) {
for (const size of sizeOptions) {
for (const weight of weightOptions) {
rows.push({ color, size, weight, price: "", stock: "" });
}
}
}
return rows;
}
function ProductVariantsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ shopId: string }>();
const shopId = params.shopId;
const { request, loading } = useAxios();
const listingId = useProductWizardId(shopId);
const [colorsInput, setColorsInput] = useState("");
const [sizesInput, setSizesInput] = useState("");
const [weightsInput, setWeightsInput] = useState("");
const [rows, setRows] = useState<VariantRow[] | null>(null);
const parsedColors = useMemo(() => parseOptions(colorsInput), [colorsInput]);
const parsedSizes = useMemo(() => parseOptions(sizesInput), [sizesInput]);
const parsedWeights = useMemo(() => parseOptions(weightsInput), [weightsInput]);
const handleGenerate = () => {
setRows(buildCombinations(parsedColors, parsedSizes, parsedWeights));
};
const updateRow = (index: number, field: "price" | "stock", value: string) => {
setRows((prev) =>
prev
? prev.map((row, i) => (i === index ? { ...row, [field]: value } : row))
: prev
);
};
const handleSubmit = async () => {
if (!listingId || !rows) return;
const variants = rows
.filter((row) => row.price && Number(row.price) > 0)
.map((row) => ({
color: row.color,
size: row.size,
weight: row.weight,
price: Number(row.price),
stock: Number(row.stock) || 0,
}));
if (variants.length === 0) {
toast.error(t("shops.variantsRequired"));
return;
}
try {
await request("PATCH", `/shop-products/${listingId}`, { variants });
clearProductWizardId(shopId);
toast.success(t("shops.productSaved"));
router.push(`/shops/${shopId}`);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!listingId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.variantsTitle")}</span>
<div className="flex w-full max-w-sm flex-col gap-2">
<RoundedInput
type="text"
placeholder={t("shops.colorsPlaceholder")}
value={colorsInput}
onChange={(e) => setColorsInput(e.target.value)}
/>
<RoundedInput
type="text"
placeholder={t("shops.sizesPlaceholder")}
value={sizesInput}
onChange={(e) => setSizesInput(e.target.value)}
/>
<RoundedInput
type="text"
placeholder={t("shops.weightsPlaceholder")}
value={weightsInput}
onChange={(e) => setWeightsInput(e.target.value)}
/>
<AuthNextButton type="button" className="mt-2" onClick={handleGenerate}>
{t("shops.generateVariants")}
</AuthNextButton>
{rows && rows.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{rows.map((row, index) => (
<div
key={`${row.color}-${row.size}-${row.weight}-${index}`}
className="flex items-center gap-2 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"
>
<span className="w-24 shrink-0 text-xs">
{[row.color, row.size, row.weight].filter(Boolean).join(" / ") ||
t("shops.defaultVariant")}
</span>
<RoundedInput
type="number"
inputMode="numeric"
placeholder={t("shops.pricePlaceholder")}
className="text-xs"
value={row.price}
onChange={(e) => updateRow(index, "price", e.target.value)}
/>
<RoundedInput
type="number"
inputMode="numeric"
placeholder={t("shops.stockPlaceholder")}
className="text-xs"
value={row.stock}
onChange={(e) => updateRow(index, "stock", e.target.value)}
/>
</div>
))}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !rows}
onClick={handleSubmit}
>
{t("shops.saveProduct")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ProductVariantsPage;

View File

@@ -0,0 +1,144 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type OrderDetail = {
_id: string;
quantity: number;
unit_price: number;
total_amount: number;
shipping_cost: number;
shipping_method?: string | null;
variantSnapshot: { color?: string | null; size?: string | null; weight?: string | null };
listing: {
title: string;
images: string[];
primaryImageIndex: number;
};
};
export default function CheckoutPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const [order, setOrder] = useState<OrderDetail | null>(null);
useEffect(() => {
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
.then((res) => setOrder(res?.order || null))
.catch(() => setOrder(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.orderId]);
const handlePay = async () => {
try {
const response = await request<{ authority?: string }>(
"POST",
"/payment/shop-order/initiate",
{ orderId: params.orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
}
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!order) return null;
const primaryImage =
order.listing.images?.[order.listing.primaryImageIndex] ||
order.listing.images?.[0];
const variantLabel = [
order.variantSnapshot.color,
order.variantSnapshot.size,
order.variantSnapshot.weight,
]
.filter(Boolean)
.join(" / ");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("shops.checkoutTitle")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={order.listing.title}
fill
className="object-cover"
sizes="64px"
unoptimized
/>
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{order.listing.title}</p>
{variantLabel && (
<p className="text-xs text-neutral-500">{variantLabel}</p>
)}
<p className="text-xs text-neutral-500">
{t("shops.quantityLabel")}: {order.quantity}
</p>
</div>
</div>
<div className="flex flex-col gap-1 text-sm">
<div className="flex justify-between">
<span>{t("shops.itemsTotal")}</span>
<span>
{order.total_amount.toLocaleString()} {t("settings.toman")}
</span>
</div>
{order.shipping_method && (
<div className="flex justify-between text-neutral-500">
<span>
{t("shops.shippingPaidAtDelivery", {
method: t(`shops.shippingMethods.${order.shipping_method}`),
})}
</span>
<span>
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
</span>
</div>
)}
<div className="mt-2 flex justify-between border-t border-neutral-200 pt-2 font-bold dark:border-neutral-700">
<span>{t("shops.payOnlineTotal")}</span>
<span>
{order.total_amount.toLocaleString()} {t("settings.toman")}
</span>
</div>
</div>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handlePay}
>
{t("shops.proceedToPayment")}
</AuthNextButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import ShopProductGrid from "@/components/shops/ShopProductGrid";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IShopProductListing } from "@/types/types";
import { useTranslation } from "react-i18next";
export default function ShopDiscoverPage() {
const { t } = useTranslation("common");
const { data, isLoading } = useInfiniteScroll({
endpoint: "/shop-products/discover",
queryKey: ["shop-products-discover"],
});
const listings: IShopProductListing[] =
data?.pages.flatMap((page) => page.docs || []) || [];
return (
<>
<Header />
<LocalePageShell>
<Container className="pb-28">
<PageTitle>{t("shops.discoverTitle")}</PageTitle>
<ShopProductGrid listings={listings} isLoading={isLoading} />
</Container>
</LocalePageShell>
<TabNavigation currentPage="/shops/discover" />
</>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type ShippingMethod = { method: string; cost: number; enabled: boolean };
type ShopInfo = { _id: string; name: string; shipping_methods: ShippingMethod[] };
export default function ListingDetailPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ listingId: string }>();
const { request, loading } = useAxios();
const [listing, setListing] = useState<IShopProductListing | null>(null);
const [shop, setShop] = useState<ShopInfo | null>(null);
const [variantId, setVariantId] = useState<string>("");
const [quantity, setQuantity] = useState(1);
const [shippingMethod, setShippingMethod] = useState<string>("");
const [showAddressModal, setShowAddressModal] = useState(false);
useEffect(() => {
request<{ listing: IShopProductListing }>(
"GET",
`/shop-products/${params.listingId}`
).then((res) => {
const data = res?.listing || null;
setListing(data);
if (data && typeof data.shop === "object") {
setShop(data.shop as unknown as ShopInfo);
}
if (data?.variants?.length) setVariantId(data.variants[0]._id);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.listingId]);
const selectedVariant = useMemo(
() => listing?.variants.find((v) => v._id === variantId) || null,
[listing, variantId]
);
const handleBuy = async () => {
if (!selectedVariant) return;
try {
const response = await request<{ order: { _id: string } }>(
"POST",
"/orders/draft",
{
listingId: listing?._id,
variantId: selectedVariant._id,
quantity,
shippingMethod: shippingMethod || undefined,
}
);
if (response?.order?._id) {
router.push(`/shops/checkout/${response.order._id}`);
}
} catch (err: unknown) {
const data = (err as { response?: { data?: { code?: string; message?: string } } })
?.response?.data;
if (data?.code === "ADDRESS_REQUIRED") {
setShowAddressModal(true);
return;
}
toast.error(data?.message || t("shops.unknownError"));
}
};
if (!listing) return null;
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
return (
<LocalePageShell>
<Container>
<PageTitle>{listing.title}</PageTitle>
<div className="mx-auto flex max-w-md flex-col gap-4">
{primaryImage && (
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={listing.title}
fill
className="object-cover"
sizes="400px"
unoptimized
/>
</div>
)}
{listing.description && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{listing.description}
</p>
)}
{listing.variants.length > 1 && (
<div>
<p className="mb-1 text-xs font-semibold">{t("shops.variantsTitle")}</p>
<div className="flex flex-wrap gap-2">
{listing.variants.map((v) => (
<button
key={v._id}
type="button"
onClick={() => setVariantId(v._id)}
className={`rounded-full border px-3 py-1 text-xs ${
variantId === v._id
? "border-pink-500 bg-pink-500 text-white"
: "border-neutral-300 dark:border-neutral-600"
}`}
>
{[v.color, v.size, v.weight].filter(Boolean).join(" / ") ||
t("shops.defaultVariant")}
</button>
))}
</div>
</div>
)}
{selectedVariant && (
<p className="text-lg font-bold">
{t("shops.priceLabel", {
amount: (
selectedVariant.discount_price || selectedVariant.price
).toLocaleString(),
})}
</p>
)}
<div className="flex items-center gap-2">
<span className="text-xs">{t("shops.quantityLabel")}</span>
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="h-8 w-8 rounded-full border border-neutral-300 dark:border-neutral-600"
>
-
</button>
<span className="w-6 text-center text-sm">{quantity}</span>
<button
type="button"
onClick={() =>
setQuantity((q) =>
selectedVariant ? Math.min(selectedVariant.stock, q + 1) : q + 1
)
}
className="h-8 w-8 rounded-full border border-neutral-300 dark:border-neutral-600"
>
+
</button>
</div>
{shop && shop.shipping_methods.filter((m) => m.enabled).length > 0 && (
<div>
<p className="mb-1 text-xs font-semibold">{t("shops.shippingTitle")}</p>
<div className="flex flex-col gap-1">
{shop.shipping_methods
.filter((m) => m.enabled)
.map((m) => (
<label key={m.method} className="flex items-center gap-2 text-xs">
<input
type="radio"
name="shippingMethod"
checked={shippingMethod === m.method}
onChange={() => setShippingMethod(m.method)}
/>
{t(`shops.shippingMethods.${m.method}`)} {" "}
{m.cost.toLocaleString()} {t("settings.toman")}
</label>
))}
</div>
</div>
)}
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !selectedVariant}
onClick={handleBuy}
>
{t("shops.buyNow")}
</AuthNextButton>
</div>
{showAddressModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
<AuthNextButton
type="button"
onClick={() => router.push("/settings/edit/location")}
>
{t("shops.goToLocationSettings")}
</AuthNextButton>
<button
type="button"
className="mt-3 block w-full text-xs text-neutral-500"
onClick={() => setShowAddressModal(false)}
>
{t("auth.skip")}
</button>
</div>
</div>
)}
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ShopCategoryPicker from "@/components/shops/ShopCategoryPicker";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { IShopCategory } from "@/types/types";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
function ShopCategoryPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [categoryList, setCategoryList] = useState<IShopCategory[] | null>(null);
const [category, setCategory] = useState("");
const [subCategory, setSubCategory] = useState<string[]>([]);
const [displaySubCategory, setDisplaySubCategory] = useState<string | null>(
null
);
useEffect(() => {
request<{ categories: IShopCategory[] }>("GET", "/shop-categories")
.then((res) => setCategoryList(res?.categories || []))
.catch(() => setCategoryList([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCategorySelect = (value: string) => {
setCategory(value);
setSubCategory([]);
setDisplaySubCategory(null);
};
const handleSubToggle = (value: string) => {
setSubCategory((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
if (displaySubCategory === value) setDisplaySubCategory(null);
};
const handleSubmit = async () => {
if (!shopId) return;
if (!category || subCategory.length === 0 || !displaySubCategory) {
toast.error(t("shops.categoryRequired"));
return;
}
try {
await request("PATCH", `/shops/${shopId}/category`, {
category,
sub_category: subCategory.join(","),
display_sub_category: displaySubCategory,
});
router.push("/shops/new/shipping");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold">{t("shops.categoryTitle")}</span>
<ShopCategoryPicker
categoryList={categoryList}
category={category}
subCategory={subCategory}
displaySubCategory={displaySubCategory}
onCategorySelect={handleCategorySelect}
onSubCategoryToggle={handleSubToggle}
onDisplaySubCategoryChange={setDisplaySubCategory}
/>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopCategoryPage;

View File

@@ -0,0 +1,213 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const DAYS = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"] as const;
type Day = (typeof DAYS)[number];
function ShopContactPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [contact, setContact] = useState({
phone: "",
mobile: "",
telegram: "",
whatsapp: "",
instagram: "",
landline: "",
});
const [enabledDays, setEnabledDays] = useState<Record<Day, boolean>>({
sat: false,
sun: false,
mon: false,
tue: false,
wed: false,
thu: false,
fri: false,
});
const [times, setTimes] = useState<
Record<Day, { start: string; end: string }>
>({
sat: { start: "", end: "" },
sun: { start: "", end: "" },
mon: { start: "", end: "" },
tue: { start: "", end: "" },
wed: { start: "", end: "" },
thu: { start: "", end: "" },
fri: { start: "", end: "" },
});
const toggleDay = (day: Day) => {
setEnabledDays((prev) => ({ ...prev, [day]: !prev[day] }));
};
const handleSubmit = async () => {
if (!shopId) return;
const hasAnyContact = Object.values(contact).some((v) => v.trim());
if (!hasAnyContact) {
toast.error(t("shops.contactRequired"));
return;
}
const response_schedule = DAYS.filter((day) => enabledDays[day]).map(
(day) => ({
day,
start_time: times[day].start || null,
end_time: times[day].end || null,
})
);
try {
await request("PATCH", `/shops/${shopId}/contact`, {
contact,
response_schedule,
});
router.push("/shops/new/preview");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.contactTitle")}</span>
<div className="flex w-full max-w-sm flex-col gap-2">
<RoundedInput
type="text"
placeholder={t("billboards.form.landline")}
maxLength={11}
value={contact.landline}
onChange={(e) =>
setContact((prev) => ({ ...prev, landline: e.target.value }))
}
/>
<RoundedInput
type="text"
placeholder={t("shops.phonePlaceholder")}
maxLength={11}
value={contact.phone}
onChange={(e) =>
setContact((prev) => ({ ...prev, phone: e.target.value }))
}
/>
<RoundedInput
type="text"
placeholder={t("billboards.form.mobile")}
maxLength={11}
value={contact.mobile}
onChange={(e) =>
setContact((prev) => ({ ...prev, mobile: e.target.value }))
}
/>
<RoundedInput
type="text"
placeholder={t("billboards.form.telegram")}
value={contact.telegram}
onChange={(e) =>
setContact((prev) => ({ ...prev, telegram: e.target.value }))
}
/>
<RoundedInput
type="text"
placeholder={t("billboards.form.whatsapp")}
maxLength={11}
value={contact.whatsapp}
onChange={(e) =>
setContact((prev) => ({ ...prev, whatsapp: e.target.value }))
}
/>
<RoundedInput
type="text"
placeholder={t("billboards.form.instagram")}
value={contact.instagram}
onChange={(e) =>
setContact((prev) => ({ ...prev, instagram: e.target.value }))
}
/>
<p className="mt-4 mb-1 text-sm font-bold">
{t("shops.responseScheduleTitle")}
</p>
{DAYS.map((day) => (
<div key={day} className="flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
checked={enabledDays[day]}
onChange={() => toggleDay(day)}
/>
<span className="w-16 shrink-0 text-sm">
{t(`shops.days.${day}`)}
</span>
{enabledDays[day] && (
<>
<input
type="time"
className="rounded-xl border border-neutral-300 bg-white p-1 text-xs dark:bg-neutral-900"
value={times[day].start}
onChange={(e) =>
setTimes((prev) => ({
...prev,
[day]: { ...prev[day], start: e.target.value },
}))
}
/>
<span className="text-xs">-</span>
<input
type="time"
className="rounded-xl border border-neutral-300 bg-white p-1 text-xs dark:bg-neutral-900"
value={times[day].end}
onChange={(e) =>
setTimes((prev) => ({
...prev,
[day]: { ...prev[day], end: e.target.value },
}))
}
/>
</>
)}
</div>
))}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopContactPage;

View File

@@ -0,0 +1,189 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import SelectBox from "@/components/elements/SelectBox";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { ICity, IProvince } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Map, { GeolocateControl, MapMouseEvent, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
function ShopLocationPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [hasPhysicalLocation, setHasPhysicalLocation] = useState(true);
const [provinces, setProvinces] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [provinceId, setProvinceId] = useState("");
const [cityId, setCityId] = useState("");
const [neighbourhood, setNeighbourhood] = useState("");
const [address, setAddress] = useState("");
const [marker, setMarker] = useState<{ lat: number; lng: number } | null>(
null
);
useEffect(() => {
request<{ provinces: IProvince[] }>("GET", "/provinces")
.then((res) => setProvinces(res?.provinces || []))
.catch(() => setProvinces([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const id = e.target.value;
setProvinceId(id);
setCityId("");
request<{ cities: ICity[] }>("GET", `/cities/${id}`)
.then((res) => setCities(res?.cities || []))
.catch(() => setCities([]));
};
const handleMapClick = (event: MapMouseEvent) => {
setMarker({ lat: event.lngLat.lat, lng: event.lngLat.lng });
};
const handleSubmit = async () => {
if (!shopId) return;
const province = provinces?.find((p) => String(p.id) === provinceId) || null;
const city = cities?.find((c) => String(c.id) === cityId) || null;
try {
await request("PATCH", `/shops/${shopId}/location`, {
has_physical_location: hasPhysicalLocation,
province,
city,
neighbourhood,
address,
lat: marker?.lat != null ? String(marker.lat) : null,
lng: marker?.lng != null ? String(marker.lng) : null,
});
router.push("/shops/new/contact");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.locationTitle")}</span>
<div className="flex w-full max-w-sm flex-col gap-2">
<label className="mb-2 flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
checked={!hasPhysicalLocation}
onChange={() => setHasPhysicalLocation((v) => !v)}
/>
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
</label>
{hasPhysicalLocation && (
<>
<SelectBox value={provinceId} onChange={handleProvinceChange}>
<option disabled value="">
{t("filters.province")}
</option>
{provinces?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</SelectBox>
<SelectBox
value={cityId}
onChange={(e) => setCityId(e.target.value)}
>
<option disabled value="">
{t("filters.city")}
</option>
{cities?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</SelectBox>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopLocationPage;

View File

@@ -0,0 +1,112 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
function ShopLogoPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [logoFile, setLogoFile] = useState<File | null>(null);
const selectImage = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setLogoFile(file);
const reader = new FileReader();
reader.onload = (e) => setLogoPreview(e.target?.result as string);
reader.readAsDataURL(file);
};
const goNext = () => router.push("/shops/new/category");
const handleUpload = async () => {
if (!logoFile || !shopId) {
toast.error(t("shops.logoRequired"));
return;
}
const formData = new FormData();
formData.append("logo", logoFile);
try {
await request("PATCH", `/shops/${shopId}/logo`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
goNext();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.logoTitle")}</span>
<div className="relative aspect-square w-[200px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
{logoPreview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPreview}
alt="shop logo"
className="h-full w-full object-cover"
/>
) : (
<label
htmlFor="shopLogoInput"
className="flex h-full w-full cursor-pointer items-center justify-center"
>
<Image
src="/images/icons/gallery-add.svg"
width={100}
height={100}
alt="add logo icon"
/>
</label>
)}
</div>
<input
id="shopLogoInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<AuthNextButton className="mt-4" type="button">
<label className="h-full w-full cursor-pointer" htmlFor="shopLogoInput">
{t("shops.selectLogo")}
</label>
</AuthNextButton>
</AuthPageContent>
<AuthFormFooter onSkip={goNext} skipDisabled={loading}>
<AuthNextButton onClick={handleUpload} loading={loading} disabled={loading}>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopLogoPage;

View File

@@ -0,0 +1,86 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthInput from "@/components/auth/AuthInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
function ShopNamePage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const formik = useFormik({
initialValues: { name: "" },
validationSchema: yup.object({
name: yup.string().trim().required(t("shops.nameRequired")),
}),
onSubmit: async (values) => {
try {
const response = await request<{ shopId: string }>(
"POST",
"/shops/draft",
{ name: values.name.trim() }
);
if (typeof window !== "undefined" && response?.shopId) {
localStorage.setItem("shop_wizard_shop_id", String(response.shopId));
}
router.push("/shops/new/logo");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
},
});
return (
<LocalePageShell>
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.nameTitle")}</span>
<AuthInput
name="name"
type="text"
placeholder={t("shops.namePlaceholder")}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={Boolean(formik.touched.name && formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && (
<small className="mt-2 block text-center text-red-500">
{formik.errors.name}
</small>
)}
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="submit"
loading={loading}
disabled={loading || !formik.values.name.trim()}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopNamePage;

View File

@@ -0,0 +1,115 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type ShopPreview = {
name: string;
logo?: string | null;
category?: string | null;
sub_category?: string | null;
shipping_methods?: { method: string; cost: number }[];
address?: string | null;
has_physical_location?: boolean;
contact?: { mobile?: string | null; telegram?: string | null } | null;
};
function ShopPreviewPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [shop, setShop] = useState<ShopPreview | null>(null);
useEffect(() => {
if (!shopId) return;
request<{ shop: ShopPreview }>("GET", `/shops/${shopId}`)
.then((res) => setShop(res?.shop || null))
.catch(() => setShop(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shopId]);
const handleSubmit = async () => {
if (!shopId) return;
try {
await request("POST", `/shops/${shopId}/submit`);
if (typeof window !== "undefined") {
localStorage.removeItem("shop_wizard_shop_id");
}
toast.success(t("shops.submitSuccess"));
router.push("/settings/shop");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId || !shop) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.previewTitle")}</span>
<div className="flex w-full max-w-sm flex-col items-center gap-3 text-center">
{shop.logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shop.logo}
alt={shop.name}
className="h-24 w-24 rounded-2xl object-cover"
/>
)}
<p className="text-lg font-bold">{shop.name}</p>
{shop.category && (
<p className="text-sm text-neutral-500">
{shop.category}
{shop.sub_category ? ` / ${shop.sub_category}` : ""}
</p>
)}
{shop.shipping_methods && shop.shipping_methods.length > 0 && (
<div className="w-full text-sm">
<p className="mb-1 font-semibold">{t("shops.shippingTitle")}</p>
{shop.shipping_methods.map((m) => (
<p key={m.method} className="text-neutral-500">
{t(`shops.shippingMethods.${m.method}`)} {" "}
{m.cost.toLocaleString()} {t("settings.toman")}
</p>
))}
</div>
)}
{shop.has_physical_location && shop.address && (
<p className="text-sm text-neutral-500">{shop.address}</p>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.submitShop")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopPreviewPage;

View File

@@ -0,0 +1,182 @@
"use client";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useShopWizardId } from "@/hooks/useShopWizardId";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const SHIPPING_METHODS = [
"post",
"tipax",
"chapar",
"intercity_freight",
"courier",
"own_vehicle",
] as const;
type ShippingMethod = (typeof SHIPPING_METHODS)[number];
function ShopShippingPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const shopId = useShopWizardId();
const [enabledMethods, setEnabledMethods] = useState<
Record<ShippingMethod, boolean>
>({
post: false,
tipax: false,
chapar: false,
intercity_freight: false,
courier: false,
own_vehicle: false,
});
const [costs, setCosts] = useState<Record<ShippingMethod, string>>({
post: "",
tipax: "",
chapar: "",
intercity_freight: "",
courier: "",
own_vehicle: "",
});
const [codAvailable, setCodAvailable] = useState(false);
const [sameDayAvailable, setSameDayAvailable] = useState(false);
const [freeShippingThreshold, setFreeShippingThreshold] = useState("");
const [estimatedDeliveryText, setEstimatedDeliveryText] = useState("");
const toggleMethod = (method: ShippingMethod) => {
setEnabledMethods((prev) => ({ ...prev, [method]: !prev[method] }));
};
const handleSubmit = async () => {
if (!shopId) return;
const shipping_methods = SHIPPING_METHODS.filter(
(method) => enabledMethods[method]
).map((method) => ({
method,
cost: Number(costs[method]) || 0,
enabled: true,
}));
if (shipping_methods.length === 0) {
toast.error(t("shops.shippingMethodRequired"));
return;
}
try {
await request("PATCH", `/shops/${shopId}/shipping`, {
shipping_methods,
cod_available: codAvailable,
same_day_available: sameDayAvailable,
free_shipping_threshold: freeShippingThreshold
? Number(freeShippingThreshold)
: null,
estimated_delivery_text: estimatedDeliveryText || null,
});
router.push("/shops/new/location");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!shopId) return null;
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.shippingTitle")}</span>
<div className="flex w-full max-w-sm flex-col gap-3">
{SHIPPING_METHODS.map((method) => (
<div key={method} className="flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
checked={enabledMethods[method]}
onChange={() => toggleMethod(method)}
/>
<span className="w-32 shrink-0 text-sm">
{t(`shops.shippingMethods.${method}`)}
</span>
{enabledMethods[method] && (
<RoundedInput
type="number"
inputMode="numeric"
placeholder={t("shops.shippingCostPlaceholder")}
className="text-sm"
value={costs[method]}
onChange={(e) =>
setCosts((prev) => ({ ...prev, [method]: e.target.value }))
}
/>
)}
</div>
))}
<label className="mt-2 flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
checked={codAvailable}
onChange={() => setCodAvailable((v) => !v)}
/>
<span className="text-sm">{t("shops.codAvailable")}</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
checked={sameDayAvailable}
onChange={() => setSameDayAvailable((v) => !v)}
/>
<span className="text-sm">{t("shops.sameDayAvailable")}</span>
</label>
<RoundedInput
type="number"
inputMode="numeric"
className="mt-2"
placeholder={t("shops.freeShippingThresholdPlaceholder")}
value={freeShippingThreshold}
onChange={(e) => setFreeShippingThreshold(e.target.value)}
/>
<RoundedInput
type="text"
className="mt-2"
placeholder={t("shops.estimatedDeliveryPlaceholder")}
value={estimatedDeliveryText}
onChange={(e) => setEstimatedDeliveryText(e.target.value)}
/>
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default ShopShippingPage;

View File

@@ -0,0 +1,468 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import RoundedInput from "@/components/elements/RoundedInput";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const TRACKING_REQUIRED_METHODS = ["post", "tipax", "chapar", "intercity_freight"];
const SELLER_STATUSES = ["on_hold", "processing", "shipped", "ready_for_pickup"];
type OrderDetail = {
_id: string;
quantity: number;
unit_price: number;
total_amount: number;
shipping_cost: number;
shipping_method?: string | null;
tracking_code?: string | null;
status: string;
createdAt: string;
buyer: string;
returnWindowExpiresAt?: string | null;
variantSnapshot: { color?: string | null; size?: string | null; weight?: string | null };
buyerAddressSnapshot?: {
first_name?: string | null;
last_name?: string | null;
address?: string | null;
province?: { name?: string } | null;
city?: { name?: string } | null;
} | null;
listing: { title: string; images: string[]; primaryImageIndex: number };
shop: { _id: string; name: string; owner: string };
};
export default function OrderDetailPage() {
const { t } = useTranslation("common");
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const user = useUser();
const [order, setOrder] = useState<OrderDetail | null>(null);
const [trackingCode, setTrackingCode] = useState("");
const [printMode, setPrintMode] = useState<"label-a5" | "label-a6" | "invoice" | null>(
null
);
const [showRating, setShowRating] = useState(false);
const [ratingScore, setRatingScore] = useState(5);
const [ratingComment, setRatingComment] = useState("");
const [showReport, setShowReport] = useState(false);
const [reportText, setReportText] = useState("");
const [showReturn, setShowReturn] = useState(false);
const [returnReason, setReturnReason] = useState("");
const loadOrder = () => {
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
.then((res) => {
setOrder(res?.order || null);
setTrackingCode(res?.order?.tracking_code || "");
})
.catch(() => setOrder(null));
};
useEffect(() => {
loadOrder();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.orderId]);
useEffect(() => {
if (!printMode) return;
const timer = setTimeout(() => {
window.print();
setPrintMode(null);
}, 100);
return () => clearTimeout(timer);
}, [printMode]);
const isSeller = Boolean(order && user && order.shop.owner === user._id);
const isBuyer = Boolean(order && user && order.buyer === user._id);
const canConfirmReceipt =
isBuyer && order && ["shipped", "ready_for_pickup"].includes(order.status);
const canRequestReturn =
isBuyer &&
order?.returnWindowExpiresAt &&
new Date() < new Date(order.returnWindowExpiresAt);
const handleConfirmReceipt = async () => {
if (!order) return;
try {
await request("PATCH", `/orders/${order._id}/confirm-receipt`, {});
toast.success(t("shops.receiptConfirmed"));
loadOrder();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleSubmitRating = async () => {
if (!order) return;
try {
await request("POST", `/orders/${order._id}/rate`, {
rating: ratingScore,
comment: ratingComment || undefined,
});
toast.success(t("shops.ratingSubmitted"));
setShowRating(false);
setRatingComment("");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleSubmitReport = async () => {
if (!order || !reportText.trim()) return;
try {
await request("POST", `/orders/${order._id}/report`, {
description: reportText.trim(),
});
toast.success(t("shops.reportSubmitted"));
setShowReport(false);
setReportText("");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleSubmitReturn = async () => {
if (!order || !returnReason.trim()) return;
try {
await request("POST", `/orders/${order._id}/return-request`, {
reason: returnReason.trim(),
});
toast.success(t("shops.returnRequestSubmitted"));
setShowReturn(false);
setReturnReason("");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleSaveTracking = async () => {
if (!order || !trackingCode.trim()) return;
try {
await request("PATCH", `/orders/${order._id}/tracking-code`, {
tracking_code: trackingCode.trim(),
});
toast.success(t("shops.trackingSaved"));
loadOrder();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
const handleStatusChange = async (status: string) => {
if (!order) return;
if (
status === "shipped" &&
order.shipping_method &&
TRACKING_REQUIRED_METHODS.includes(order.shipping_method) &&
!trackingCode.trim()
) {
toast.error(t("shops.trackingRequiredForShipping"));
return;
}
try {
await request("PATCH", `/orders/${order._id}/status`, { status });
toast.success(t("shops.statusUpdated"));
loadOrder();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
}
};
if (!order) return null;
const primaryImage =
order.listing.images?.[order.listing.primaryImageIndex] ||
order.listing.images?.[0];
const buyerName = [
order.buyerAddressSnapshot?.first_name,
order.buyerAddressSnapshot?.last_name,
]
.filter(Boolean)
.join(" ");
return (
<LocalePageShell>
<Container>
<div className="no-print">
<PageTitle>{t("shops.orderDetailTitle")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={order.listing.title}
fill
className="object-cover"
sizes="64px"
unoptimized
/>
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{order.listing.title}</p>
<p className="text-xs text-neutral-500">
{order.total_amount.toLocaleString()} {t("settings.toman")}
</p>
</div>
</div>
<div className="text-sm">
<p className="mb-1 font-semibold">{t("shops.buyerInfoTitle")}</p>
<p className="text-neutral-500">{buyerName}</p>
<p className="text-neutral-500">
{order.buyerAddressSnapshot?.province?.name}{" "}
{order.buyerAddressSnapshot?.city?.name}
</p>
<p className="text-neutral-500">{order.buyerAddressSnapshot?.address}</p>
<p className="mt-1 text-neutral-500">{order.createdAt}</p>
{order.shipping_method && (
<p className="text-neutral-500">
{t(`shops.shippingMethods.${order.shipping_method}`)}
</p>
)}
{order.tracking_code &&
(order.shipping_method === "post" ? (
<a
href={`https://tracking.post.ir/?id=${order.tracking_code}`}
target="_blank"
rel="noopener noreferrer"
className="font-bold text-blue-600 underline dark:text-blue-400"
>
{t("shops.trackingCodeLabel")}: {order.tracking_code}
</a>
) : (
<p className="font-bold">
{t("shops.trackingCodeLabel")}: {order.tracking_code}
</p>
))}
</div>
<div className="flex gap-2">
<AuthNextButton type="button" onClick={() => setPrintMode("label-a5")}>
{t("shops.printLabelA5")}
</AuthNextButton>
<AuthNextButton type="button" onClick={() => setPrintMode("label-a6")}>
{t("shops.printLabelA6")}
</AuthNextButton>
</div>
<AuthNextButton type="button" onClick={() => setPrintMode("invoice")}>
{t("shops.printInvoice")}
</AuthNextButton>
{isSeller && (
<>
<div>
<p className="mb-1 text-xs font-semibold">{t("shops.trackingCodeLabel")}</p>
<div className="flex gap-2">
<RoundedInput
type="text"
value={trackingCode}
onChange={(e) => setTrackingCode(e.target.value)}
/>
<AuthNextButton
type="button"
loading={loading}
disabled={loading}
onClick={handleSaveTracking}
>
{t("common.save")}
</AuthNextButton>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold">{t("shops.changeStatusLabel")}</p>
<div className="flex flex-wrap gap-2">
{SELLER_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => handleStatusChange(status)}
className={`rounded-full border px-3 py-1 text-xs ${
order.status === status
? "border-pink-500 bg-pink-500 text-white"
: "border-neutral-300 dark:border-neutral-600"
}`}
>
{t(`shops.orderStatus.${status}`)}
</button>
))}
</div>
</div>
</>
)}
{isBuyer && (
<>
{canConfirmReceipt && (
<AuthNextButton type="button" onClick={handleConfirmReceipt}>
{t("shops.confirmReceipt")}
</AuthNextButton>
)}
<AuthNextButton type="button" onClick={() => setShowRating((v) => !v)}>
{t("shops.rateShop")}
</AuthNextButton>
{showRating && (
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<div className="mb-2 flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setRatingScore(n)}
className={`h-8 w-8 rounded-full border text-xs ${
ratingScore >= n
? "border-pink-500 bg-pink-500 text-white"
: "border-neutral-300"
}`}
>
{n}
</button>
))}
</div>
<textarea
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
placeholder={t("shops.ratingCommentPlaceholder")}
value={ratingComment}
onChange={(e) => setRatingComment(e.target.value)}
/>
<AuthNextButton
type="button"
className="mt-2"
onClick={handleSubmitRating}
>
{t("common.save")}
</AuthNextButton>
</div>
)}
<AuthNextButton type="button" onClick={() => setShowReport((v) => !v)}>
{t("shops.reportProblem")}
</AuthNextButton>
{showReport && (
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<textarea
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
placeholder={t("shops.reportPlaceholder")}
value={reportText}
onChange={(e) => setReportText(e.target.value)}
/>
<AuthNextButton
type="button"
className="mt-2"
onClick={handleSubmitReport}
>
{t("shops.submitReport")}
</AuthNextButton>
</div>
)}
{canRequestReturn && (
<>
<AuthNextButton type="button" onClick={() => setShowReturn((v) => !v)}>
{t("shops.requestReturn")}
</AuthNextButton>
{showReturn && (
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<textarea
className="h-20 w-full rounded-2xl border border-neutral-300 bg-white p-2 text-xs dark:border-neutral-600 dark:bg-neutral-950"
placeholder={t("shops.returnReasonPlaceholder")}
value={returnReason}
onChange={(e) => setReturnReason(e.target.value)}
/>
<AuthNextButton
type="button"
className="mt-2"
onClick={handleSubmitReturn}
>
{t("shops.submitReturn")}
</AuthNextButton>
</div>
)}
</>
)}
</>
)}
</div>
</div>
{printMode && (
<div className="print-only">
{(printMode === "label-a5" || printMode === "label-a6") && (
<div className="p-6 text-sm">
<p className="mb-4 text-lg font-bold">{order.shop.name}</p>
<p>{t("shops.buyerInfoTitle")}:</p>
<p>{buyerName}</p>
<p>
{order.buyerAddressSnapshot?.province?.name}{" "}
{order.buyerAddressSnapshot?.city?.name}
</p>
<p>{order.buyerAddressSnapshot?.address}</p>
{order.tracking_code && (
<p className="mt-4">
{t("shops.trackingCodeLabel")}: {order.tracking_code}
</p>
)}
</div>
)}
{printMode === "invoice" && (
<div className="p-6 text-sm">
<p className="mb-4 text-lg font-bold">{t("shops.invoiceTitle")}</p>
<p>{order.listing.title}</p>
<p>
{t("shops.quantityLabel")}: {order.quantity}
</p>
<p>
{order.total_amount.toLocaleString()} {t("settings.toman")}
</p>
<p className="mt-4">{buyerName}</p>
<p>{order.createdAt}</p>
</div>
)}
</div>
)}
<style>{`
.print-only { display: none; }
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
@page { size: ${printMode === "label-a6" ? "A6" : "A5"}; }
}
`}</style>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTranslation } from "react-i18next";
export default function ShopPaymentFailedPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const searchParams = useSearchParams();
const orderId = searchParams.get("orderId");
const retry = async () => {
if (!orderId) return;
try {
const response = await request<{ authority?: string }>(
"POST",
"/payment/shop-order/initiate",
{ orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
}
} catch {
// toast already shown by the axios interceptor
}
};
return (
<LocalePageShell>
<Container>
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-red-600 md:text-xl">
{t("shops.paymentFailedTitle")}
</h6>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src="/images/icons/failed.svg"
className="mb-5 pb-1"
/>
<span>{t("shops.paymentFailedHint")}</span>
</div>
<div className="mt-10 flex flex-col items-center gap-4">
<RoundedButton
variant="primary"
className="h-9 w-32"
onClick={retry}
disabled={loading || !orderId}
>
{t("shops.retryPayment")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { useTranslation } from "react-i18next";
export default function ShopPaymentSuccessPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const orderId = searchParams.get("orderId");
return (
<LocalePageShell>
<Container>
<h6 className="mb-4 mt-2 text-center text-lg font-bold text-green-600 md:text-xl">
{t("shops.paymentSuccessTitle")}
</h6>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src="/images/icons/success.svg"
className="mb-5 pb-1"
/>
<span>{t("shops.paymentSuccessHint")}</span>
</div>
<div className="mt-10 flex flex-col items-center gap-4">
<RoundedButton
variant="primary"
className="h-9 w-48"
onClick={() =>
router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop")
}
>
{t("shops.viewOrder")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { 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 ProductComparisonPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ catalogId: string }>();
const { request } = useAxios();
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
useEffect(() => {
request<{ listings: IShopProductListing[] }>(
"GET",
`/shop-products/catalog/${params.catalogId}/listings`
)
.then((res) => setListings(res?.listings || []))
.catch(() => setListings([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.catalogId]);
const productName =
listings && listings.length > 0
? typeof listings[0].catalogProduct === "object"
? listings[0].catalogProduct?.name
: listings[0].title
: "";
return (
<LocalePageShell>
<Container>
<PageTitle>{productName || t("shops.comparisonTitle")}</PageTitle>
{listings === null ? (
<p className="py-16 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : listings.length === 0 ? (
<p className="py-16 text-center text-sm text-neutral-500">
{t("shops.noListingsForProduct")}
</p>
) : (
<div className="mx-auto mt-4 flex max-w-md flex-col gap-3">
{listings.map((listing) => {
const shop =
typeof listing.shop === "object" ? listing.shop : null;
const primaryImage =
listing.images?.[listing.primaryImageIndex] ||
listing.images?.[0];
const price = cheapestPrice(listing);
return (
<button
key={listing._id}
type="button"
onClick={() => router.push(`/shops/listing/${listing._id}`)}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
>
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={listing.title}
fill
className="object-cover"
sizes="64px"
unoptimized
/>
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-500">
{shop?.name}
</p>
{price != null && (
<p className="text-sm font-bold">
{t("shops.priceLabel", {
amount: price.toLocaleString(),
})}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useTranslation } from "react-i18next";
import {
buildInstagramAuthorizeUrl,
type InstagramAuthMode,
} from "@/config/instagramAuth";
type InstagramSignInButtonProps = {
mode?: InstagramAuthMode;
clientId?: string;
};
function InstagramIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<defs>
<linearGradient id="ig-gradient" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FFDD55" />
<stop offset="50%" stopColor="#E1306C" />
<stop offset="100%" stopColor="#5851DB" />
</linearGradient>
</defs>
<path
fill="url(#ig-gradient)"
d="M12 2c2.7 0 3.06.01 4.12.06 1.06.05 1.79.22 2.43.46.66.26 1.22.6 1.77 1.15.55.55.89 1.11 1.15 1.77.24.64.41 1.37.46 2.43.05 1.06.06 1.42.06 4.13s-.01 3.06-.06 4.12c-.05 1.06-.22 1.79-.46 2.43a4.9 4.9 0 01-1.15 1.77c-.55.55-1.11.89-1.77 1.15-.64.24-1.37.41-2.43.46-1.06.05-1.42.06-4.12.06s-3.07-.01-4.13-.06c-1.06-.05-1.79-.22-2.43-.46a4.9 4.9 0 01-1.77-1.15 4.9 4.9 0 01-1.15-1.77c-.24-.64-.41-1.37-.46-2.43C2.01 15.07 2 14.7 2 12s.01-3.07.06-4.13c.05-1.06.22-1.79.46-2.43.26-.66.6-1.22 1.15-1.77.55-.55 1.11-.89 1.77-1.15.64-.24 1.37-.41 2.43-.46C8.93 2.01 9.3 2 12 2zm0 1.8c-2.67 0-2.99.01-4.04.06-.97.04-1.5.2-1.85.34-.47.18-.8.4-1.15.75s-.57.68-.75 1.15c-.14.35-.3.88-.34 1.85C3.81 9 3.8 9.33 3.8 12s.01 2.99.06 4.04c.04.97.2 1.5.34 1.85.18.47.4.8.75 1.15s.68.57 1.15.75c.35.14.88.3 1.85.34 1.05.05 1.37.06 4.04.06s2.99-.01 4.04-.06c.97-.04 1.5-.2 1.85-.34.47-.18.8-.4 1.15-.75s.57-.68.75-1.15c.14-.35.3-.88.34-1.85.05-1.05.06-1.37.06-4.04s-.01-2.99-.06-4.04c-.04-.97-.2-1.5-.34-1.85a3.1 3.1 0 00-.75-1.15 3.1 3.1 0 00-1.15-.75c-.35-.14-.88-.3-1.85-.34C14.99 3.81 14.67 3.8 12 3.8zm0 3.05a5.15 5.15 0 110 10.3 5.15 5.15 0 010-10.3zm0 1.8a3.35 3.35 0 100 6.7 3.35 3.35 0 000-6.7zm5.35-1.99a1.2 1.2 0 11-2.4 0 1.2 1.2 0 012.4 0z"
/>
</svg>
);
}
function generateNonce(): string {
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export default function InstagramSignInButton({
mode = "login",
clientId = "",
}: InstagramSignInButtonProps) {
const { t } = useTranslation("common");
const effectiveClientId = clientId.trim();
const label =
mode === "register"
? t("auth.instagramRegister")
: mode === "link"
? t("settings.edit.instagramImport.linkButton")
: t("auth.instagramLogin");
const dividerLabel =
mode === "register"
? t("auth.orRegisterWithInstagram")
: mode === "link"
? t("settings.edit.instagramImport.orLinkWith")
: t("auth.orLoginWithInstagram");
const handleClick = () => {
const nonce = generateNonce();
sessionStorage.setItem("ig_oauth_mode", mode);
sessionStorage.setItem("ig_oauth_nonce", nonce);
window.location.assign(
buildInstagramAuthorizeUrl({ clientId: effectiveClientId, mode, nonce })
);
};
if (!effectiveClientId) return null;
return (
<div className="w-full max-w-[290px] mt-5 flex flex-col items-center">
<div className="relative flex items-center justify-center mb-4 w-full">
<span className="absolute inset-x-0 h-px bg-gray-200 dark:bg-gray-700" />
<span className="relative bg-white dark:bg-[#1a1a1a] px-3 text-xs text-gray-500">
{dividerLabel}
</span>
</div>
<button
type="button"
onClick={handleClick}
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-gray-200 bg-white p-3 text-center text-base font-bold text-gray-800 shadow-sm dark:bg-white dark:text-gray-900"
>
<InstagramIcon />
<span>{label}</span>
</button>
</div>
);
}

View File

@@ -199,6 +199,14 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
>
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
<button
type="button"
onClick={() => router.push("/shops/discover")}
aria-label={t("filters.toolbar.shop")}
className={toolbarIconButtonClass}
>
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
</div>
{showFilterModal && (
<FilterModal

View File

@@ -14,10 +14,12 @@ import Link from "next/link";
import { useTranslation } from "react-i18next";
const SETTINGS_NAV = [
{ key: "wallet", href: "/wallet", icon: "card.svg" },
{ key: "workroom", href: "/workroom", icon: "box.svg" },
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
{ key: "offers", href: "/offers", icon: "offer.svg" },
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
{ key: "chats", href: "/chats", icon: "sms.svg" },
{ key: "notifications", href: "/notifications", icon: "notification.svg" },

View File

@@ -0,0 +1,145 @@
"use client";
import { IShopCategory } from "@/types/types";
import { cn } from "@/lib/utils";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
function gridColsClass(count: number): string {
if (count <= 1) return "grid-cols-1";
if (count === 2) return "grid-cols-2";
if (count === 3) return "grid-cols-3";
if (count === 4) return "grid-cols-4";
if (count === 5) return "grid-cols-5";
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-5";
}
function gridMaxWidth(count: number): number {
const cols = Math.min(Math.max(count, 1), 5);
return cols * 108;
}
type ShopCategoryPickerProps = {
categoryList: IShopCategory[] | null;
category: string;
subCategory: string[];
displaySubCategory: string | null;
onCategorySelect: (category: string) => void;
onSubCategoryToggle: (subCategory: string) => void;
onDisplaySubCategoryChange: (subCategory: string | null) => void;
};
export default function ShopCategoryPicker({
categoryList,
category,
subCategory,
displaySubCategory,
onCategorySelect,
onSubCategoryToggle,
onDisplaySubCategoryChange,
}: ShopCategoryPickerProps) {
const { t } = useTranslation("common");
const mainCount = categoryList?.length ?? 0;
const selectedCategory = categoryList?.find(
(item) => item.category === category
);
const subCount = selectedCategory?.sub_categories.length ?? 0;
if (!mainCount) {
return (
<p className="mt-4 text-center text-sm text-gray-500">
{t("shops.loadingCategories")}
</p>
);
}
return (
<div className="mt-4 flex w-full flex-col items-center px-2">
<div
className={cn("grid w-full gap-2", gridColsClass(mainCount))}
style={{ maxWidth: gridMaxWidth(mainCount) }}
>
{categoryList?.map((item) => (
<button
key={item._id}
type="button"
className={cn(
toggleBtnClass(category === item.category),
"min-h-10 p-2 text-xs leading-tight"
)}
onClick={() => onCategorySelect(item.category)}
>
{item.category}
</button>
))}
</div>
{selectedCategory && subCount > 0 && (
<div className="mt-6 flex w-full flex-col items-center">
<p className="mb-1 text-center text-xs text-gray-500">
{t("shops.selectSubCategory")}
</p>
<p className="mb-2 text-center text-xs text-gray-400">
{t("shops.subCategoryDisplayHint")}
</p>
<div
className={cn("grid w-full gap-2", gridColsClass(subCount))}
style={{ maxWidth: gridMaxWidth(subCount) }}
>
{selectedCategory.sub_categories.map((item) => {
const isSelected = subCategory.includes(item.name);
const isDisplay =
!!displaySubCategory &&
displaySubCategory.trim() === item.name.trim();
return (
<div key={item._id} className="flex items-stretch gap-1.5">
<button
type="button"
aria-label={t("shops.displaySubCategoryAria", {
name: item.name,
})}
disabled={!isSelected}
className={cn(
"flex h-10 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors",
!isSelected && "cursor-not-allowed opacity-40",
isDisplay && isSelected
? "border-pink-500 bg-pink-500 text-white"
: "border-neutral-300 bg-white dark:bg-neutral-900"
)}
onClick={() => {
if (!isSelected) return;
onDisplaySubCategoryChange(item.name);
}}
>
{isDisplay && isSelected ? (
<BoldIcon
name="tick-circle"
size={14}
tinted
className="text-white"
/>
) : (
<span className="h-3.5 w-3.5 rounded-sm border border-neutral-300 dark:border-neutral-600" />
)}
</button>
<button
type="button"
className={cn(
toggleBtnClass(isSelected),
"min-h-10 flex-1 p-2 text-xs leading-tight"
)}
onClick={() => onSubCategoryToggle(item.name)}
>
{item.name}
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
type ShopProductAuthorProps = {
shopName: string;
shopLogo?: string | null;
};
export default function ShopProductAuthor({
shopName,
shopLogo,
}: ShopProductAuthorProps) {
return (
<span className="flex min-w-0 items-center gap-1.5">
{shopLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shopLogo}
alt={shopName}
className="h-5 w-5 shrink-0 rounded-full object-cover"
/>
) : (
<span className="h-5 w-5 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
{shopName}
</span>
</span>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ShopProductAuthor from "@/components/shops/ShopProductAuthor";
import { IShopProductListing } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
type ShopProductGridProps = {
listings: IShopProductListing[];
/** Fallback shop name/logo used when `listing.shop` isn't a populated object (single-shop pages). */
shopName?: string;
shopLogo?: string | null;
isLoading?: boolean;
/** Defaults to the Torob-style price-comparison page for the listing's catalog product. */
getHref?: (listing: IShopProductListing) => string;
};
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));
}
function defaultHref(listing: IShopProductListing): string {
const catalogId =
typeof listing.catalogProduct === "string"
? listing.catalogProduct
: listing.catalogProduct?._id;
return `/shops/product/${catalogId}`;
}
function ProductCardSkeleton() {
return (
<article className="mb-2 break-inside-avoid">
<div className="relative aspect-square w-full animate-pulse overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800" />
<div className="flex flex-col gap-1 px-0.5 py-1.5">
<div className="h-3 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="h-3 w-14 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
</div>
</article>
);
}
export default function ShopProductGrid({
listings,
shopName,
shopLogo,
isLoading,
getHref = defaultHref,
}: ShopProductGridProps) {
const { t } = useTranslation("common");
const router = useRouter();
if (isLoading) {
return (
<div className="px-2 pb-2">
<div className="columns-2 gap-2">
{Array.from({ length: 6 }).map((_, index) => (
<ProductCardSkeleton key={index} />
))}
</div>
</div>
);
}
if (!listings.length) {
return (
<p className="py-16 text-center text-sm text-neutral-500">
{t("shops.noProductsYet")}
</p>
);
}
return (
<div className="px-2 pb-2">
<div className="columns-2 gap-2">
{listings.map((listing) => {
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
const price = cheapestPrice(listing);
const cardShop =
typeof listing.shop === "object" && listing.shop
? listing.shop
: null;
return (
<article key={listing._id} className="mb-2 break-inside-avoid">
<button
type="button"
onClick={() => router.push(getHref(listing))}
className="gentle-transition block w-full text-right active:scale-[0.98]"
>
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900">
{primaryImage ? (
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={listing.title}
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
) : null}
{listing.status !== "active" && (
<span className="absolute left-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{t(`shops.status.${listing.status}`)}
</span>
)}
</div>
</button>
<div className="flex flex-col gap-1 px-0.5 py-1.5">
<ShopProductAuthor
shopName={cardShop?.name || shopName || ""}
shopLogo={cardShop?.logo ?? shopLogo}
/>
<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

@@ -0,0 +1,80 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
type BuyerOrder = {
_id: string;
total_amount: number;
status: string;
createdAt: string;
shipping_method?: string | null;
tracking_code?: string | null;
shop?: { name: string } | string;
listing?: { title: string; images: string[]; primaryImageIndex: number } | string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-[#BFAF19]",
on_hold: "text-[#BFAF19]",
failed: "text-[#BA4141]",
processing: "text-[#3D7EFF]",
shipped: "text-[#3D7EFF]",
ready_for_pickup: "text-[#3D7EFF]",
completed: "text-[#008D0E]",
cancelled: "text-[#BA4141]",
refunded: "text-[#BA4141]",
};
export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
const { t } = useTranslation("common");
const router = useRouter();
const listing = typeof order.listing === "object" ? order.listing : null;
const shopName = typeof order.shop === "object" ? order.shop?.name : "";
const primaryImage =
listing?.images?.[listing.primaryImageIndex] || listing?.images?.[0];
return (
<div
className="mb-4 block w-full cursor-pointer rounded-3xl border border-border-primary-light p-4 font-semibold"
onClick={() => router.push(`/shops/orders/${order._id}`)}
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<div className="flex min-w-0 items-center gap-2">
{primaryImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + primaryImage}
alt={listing?.title || ""}
className="h-12 w-12 shrink-0 rounded-xl object-cover"
/>
) : (
<span className="h-12 w-12 shrink-0 rounded-xl bg-neutral-200 dark:bg-neutral-800" />
)}
<div className="min-w-0">
<p className="truncate text-sm">{listing?.title}</p>
<p className="text-xs text-neutral-500">{shopName}</p>
<p className="text-xs text-neutral-500">
{order.total_amount.toLocaleString()} {t("settings.toman")}
</p>
</div>
</div>
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
<span>{order.createdAt}</span>
<div className="mt-3 flex items-center gap-1 max-[350px]:mt-0">
<span>{t("settings.status")}: </span>
<span className={STATUS_COLOR[order.status] || ""}>
{t(`shops.orderStatus.${order.status}`)}
</span>
</div>
{order.tracking_code && (
<span className="mt-1 text-[11px] text-neutral-500">
{order.tracking_code}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
type SellerOrder = {
_id: string;
quantity: number;
total_amount: number;
status: string;
createdAt: string;
shipping_method?: string | null;
tracking_code?: string | null;
buyerAddressSnapshot?: { first_name?: string | null; last_name?: string | null } | null;
listing?: { title: string; images: string[]; primaryImageIndex: number } | string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-[#BFAF19]",
on_hold: "text-[#BFAF19]",
failed: "text-[#BA4141]",
processing: "text-[#3D7EFF]",
shipped: "text-[#3D7EFF]",
ready_for_pickup: "text-[#3D7EFF]",
completed: "text-[#008D0E]",
cancelled: "text-[#BA4141]",
refunded: "text-[#BA4141]",
};
export default function SellerOrderItem({ order }: { order: SellerOrder }) {
const { t } = useTranslation("common");
const router = useRouter();
const listing = typeof order.listing === "object" ? order.listing : null;
const primaryImage =
listing?.images?.[listing.primaryImageIndex] || listing?.images?.[0];
const buyerName = [
order.buyerAddressSnapshot?.first_name,
order.buyerAddressSnapshot?.last_name,
]
.filter(Boolean)
.join(" ");
return (
<div
className="mb-4 block w-full cursor-pointer rounded-3xl border border-border-primary-light p-4 font-semibold"
onClick={() => router.push(`/shops/orders/${order._id}`)}
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<div className="flex min-w-0 items-center gap-2">
{primaryImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + primaryImage}
alt={listing?.title || ""}
className="h-12 w-12 shrink-0 rounded-xl object-cover"
/>
) : (
<span className="h-12 w-12 shrink-0 rounded-xl bg-neutral-200 dark:bg-neutral-800" />
)}
<div className="min-w-0">
<p className="truncate text-sm">{listing?.title}</p>
<p className="text-xs text-neutral-500">{buyerName}</p>
<p className="text-xs text-neutral-500">
{order.total_amount.toLocaleString()} {t("settings.toman")}
</p>
</div>
</div>
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
<span>{order.createdAt}</span>
<div className="mt-3 flex items-center gap-1 max-[350px]:mt-0">
<span>{t("settings.status")}: </span>
<span className={STATUS_COLOR[order.status] || ""}>
{t(`shops.orderStatus.${order.status}`)}
</span>
</div>
{order.shipping_method && (
<span className="mt-1 text-[11px] text-neutral-500">
{t(`shops.shippingMethods.${order.shipping_method}`)}
{order.tracking_code ? `: ${order.tracking_code}` : ""}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
/**
* Instagram OAuth Client ID — public; same value as modstagram-back INSTAGRAM_CLIENT_ID.
* Left empty until a Meta App is configured; InstagramSignInButton renders nothing
* when this (and the runtime /api/auth/config value) are both empty.
*/
export const INSTAGRAM_OAUTH_CLIENT_ID = "";
/** Must exactly match INSTAGRAM_REDIRECT_URI configured on the backend and in the Meta App. */
export function getInstagramRedirectUri(): string {
if (typeof window === "undefined") return "";
return `${window.location.origin}/auth/instagram/callback`;
}
export type InstagramAuthMode = "login" | "register" | "link";
/**
* Only the scope needed to read the connected account's basic profile.
* Media-read scope is added in M2 once content import is built.
*/
const INSTAGRAM_SCOPE = "instagram_business_basic";
export function buildInstagramAuthorizeUrl({
clientId,
mode,
nonce,
}: {
clientId: string;
mode: InstagramAuthMode;
nonce: string;
}): string {
const state = encodeURIComponent(JSON.stringify({ mode, nonce }));
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: getInstagramRedirectUri(),
scope: INSTAGRAM_SCOPE,
response_type: "code",
state,
});
return `https://www.instagram.com/oauth/authorize?${params.toString()}`;
}

View File

@@ -68,6 +68,11 @@ export const editUserNavLinks = [
href: "/google-account",
icon: "google.png",
},
{
labelKey: "settings.edit.nav.instagramImport",
href: "/instagram-import",
icon: "instagram.svg",
},
{
labelKey: "settings.edit.nav.twoFactor",
href: "/two-factor",

View File

@@ -37,6 +37,7 @@ const isPublicAuthRequest = (url: string): boolean => {
/^\/register\/?$/,
/^\/register\/verify$/,
/^\/auth\/google$/,
/^\/auth\/instagram\/callback$/,
];
return publicPatterns.some((pattern) => pattern.test(path));
};

View File

@@ -0,0 +1,39 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
function storageKey(shopId: string): string {
return `shop_product_wizard_listing_id_${shopId}`;
}
export function saveProductWizardId(shopId: string, listingId: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(storageKey(shopId), listingId);
}
export function clearProductWizardId(shopId: string): void {
if (typeof window === "undefined") return;
localStorage.removeItem(storageKey(shopId));
}
/** Reads the in-progress listing id saved by the product name step; redirects back to it if missing. */
export function useProductWizardId(shopId: string): string | null {
const router = useRouter();
const [listingId, setListingId] = useState<string | null>(null);
useEffect(() => {
if (!shopId) return;
const stored =
typeof window !== "undefined" ? localStorage.getItem(storageKey(shopId)) : null;
if (!stored) {
router.replace(`/shops/${shopId}/products/new/name`);
return;
}
setListingId(stored);
}, [router, shopId]);
return listingId;
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
/** Reads the in-progress shop id saved by the name step; redirects back to it if missing. */
export function useShopWizardId(): string | null {
const router = useRouter();
const [shopId, setShopId] = useState<string | null>(null);
useEffect(() => {
const stored =
typeof window !== "undefined"
? localStorage.getItem("shop_wizard_shop_id")
: null;
if (!stored) {
router.replace("/shops/new/name");
return;
}
setShopId(stored);
}, [router]);
return shopId;
}

View File

@@ -0,0 +1,10 @@
import { INSTAGRAM_OAUTH_CLIENT_ID } from "@/config/instagramAuth";
/** Instagram OAuth Client ID — env at runtime, then committed fallback (empty until configured) */
export function getInstagramClientId(): string {
return (
process.env.NEXT_PUBLIC_INSTAGRAM_CLIENT_ID ||
process.env.INSTAGRAM_CLIENT_ID ||
INSTAGRAM_OAUTH_CLIENT_ID
).trim();
}

View File

@@ -66,7 +66,9 @@ export function parseLoginResponse(raw: unknown): IVerifyOtp {
page,
id: obj.id != null ? String(obj.id) : undefined,
auth_provider:
obj.auth_provider === "google" || obj.auth_provider === "mobile"
obj.auth_provider === "google" ||
obj.auth_provider === "mobile" ||
obj.auth_provider === "instagram"
? obj.auth_provider
: undefined,
email:

View File

@@ -0,0 +1,15 @@
/** Standard Iranian national-code (کد ملی) checksum validator. */
export function isValidIranNationalCode(code: string): boolean {
const value = String(code || "");
if (!/^\d{10}$/.test(value)) return false;
if (/^(\d)\1{9}$/.test(value)) return false;
const digits = value.split("").map(Number);
const sum = digits
.slice(0, 9)
.reduce((acc, digit, index) => acc + digit * (10 - index), 0);
const remainder = sum % 11;
const checkDigit = remainder < 2 ? remainder : 11 - remainder;
return checkDigit === digits[9];
}

View File

@@ -74,6 +74,8 @@
"billboards": "Billboards",
"academy": "Academy",
"offers": "Requests",
"shop": "Shop",
"wallet": "Wallet",
"favorites": "Saved",
"chats": "Messages",
"notifications": "Notifications",
@@ -206,6 +208,7 @@
"types": {
"offer": "Collaboration request",
"advertising": "Advertising",
"shop_order": "Shop purchase",
"other": "Other"
},
"statuses": {
@@ -256,6 +259,7 @@
"username": "Username",
"password": "Password",
"googleAccount": "Google account",
"instagramImport": "Import from Instagram",
"twoFactor": "Two-factor login",
"avatar": "Profile photo",
"authentication": "Verification",
@@ -266,7 +270,7 @@
"license": "License",
"cooperationType": "Cooperation type",
"publicRelations": "Public relations",
"shaba": "IBAN",
"shaba": "Identity Information",
"location": "Location",
"bio": "Bio"
},
@@ -287,6 +291,17 @@
"linkedHint": "Your Google account is linked:",
"unlinkedHint": "Link your Google account to sign in with Google in addition to mobile."
},
"instagramImport": {
"linkButton": "Connect Instagram account",
"orLinkWith": "Or connect with",
"linkSuccess": "Instagram account connected successfully",
"loadError": "Could not load Instagram account status",
"linkedHint": "Your Instagram account is connected:",
"unlinkedHint": "Only Instagram Business or Creator accounts can connect. Once connected, you can import your bio, posts, reels, and profile picture from Instagram.",
"disconnectButton": "Disconnect Instagram",
"disconnectSuccess": "Instagram account disconnected",
"disconnectError": "Could not disconnect Instagram account"
},
"twoFactor": {
"loadError": "Could not load two-factor status",
"setupError": "Could not start two-factor setup",
@@ -321,11 +336,21 @@
"sizeRequired": "Size is required"
},
"shaba": {
"forPayout": "For payout transfers",
"forPayout": "To receive gifts and payments from Modstagram, buyers, and other users, we need to verify your identity information.",
"placeholder": "IBAN number",
"mustBeOwn": "IBAN must be in your own name",
"mustBeOwn": "IBAN and national ID must be under the same person's name",
"required": "IBAN is required",
"invalid": "IBAN must be 24 digits"
"invalid": "IBAN is not valid",
"bankNameUnknown": "Unknown bank",
"nationalCodeInvalid": "National ID is not valid",
"verifyIdentityButton": "Verify identity",
"verifyIdentityRejectedHint": "Your previous submission was rejected; please resubmit",
"verifyIdentityPending": "Pending review",
"verifyIdentityDone": "Identity verified",
"saveButton": "Confirm",
"saveSuccess": "Information saved successfully",
"saveError": "Could not save information",
"nationalCodeDuplicate": "This national ID is already registered to another account"
},
"services": {
"minOne": "Enter at least one service."
@@ -489,6 +514,14 @@
"rules": "Terms & policies",
"googleLogin": "Continue with Google",
"googleRegister": "Sign up with Google",
"instagramLogin": "Continue with Instagram",
"instagramRegister": "Sign up with Instagram",
"orLoginWithInstagram": "Or continue with Instagram",
"orRegisterWithInstagram": "Or sign up with Instagram",
"instagramConnecting": "Connecting to Instagram...",
"instagramLoginFailed": "Instagram sign-in failed",
"instagramAccountTypeError": "Only Instagram Business or Creator accounts can connect.",
"usernameSuggestedFromInstagram": "This username was suggested from Instagram; you can change it",
"orLoginWith": "Or continue with",
"orRegisterWith": "Or sign up with",
"twoFactorTitle": "Two-factor authentication",
@@ -1542,6 +1575,7 @@
"searchUser": "Search users",
"filter": "Filter",
"explore": "Explore",
"shop": "Shop",
"newProject": "New project",
"search": "Search"
}
@@ -2134,5 +2168,160 @@
"defaultCategory": "Category",
"defaultTeacher": "Instructor",
"priceToman": "{{price}} Toman"
},
"shops": {
"loadingCategories": "Loading categories...",
"selectSubCategory": "Select your sub-categories",
"subCategoryDisplayHint": "Check an item to show it on the shop page",
"displaySubCategoryAria": "Show {{name}} on the shop",
"unknownError": "An unknown error occurred",
"saveAndContinue": "Save and continue",
"nameTitle": "Shop name",
"namePlaceholder": "Enter shop name",
"nameRequired": "Shop name is required",
"logoTitle": "Shop logo",
"selectLogo": "Select/edit logo",
"logoRequired": "Please select a shop logo",
"categoryTitle": "Shop category",
"categoryRequired": "Category, sub-category, and a display choice are required",
"shippingTitle": "Shipping methods",
"shippingMethodRequired": "Select at least one shipping method",
"shippingMethods": {
"post": "Post",
"tipax": "Tipax",
"chapar": "Chapar",
"intercity_freight": "Intercity freight",
"courier": "Courier",
"own_vehicle": "Shop's own vehicle"
},
"shippingCostPlaceholder": "Shipping cost (Toman)",
"codAvailable": "Cash on delivery available",
"sameDayAvailable": "Same-day delivery available",
"freeShippingThresholdPlaceholder": "Minimum order for free shipping (Toman)",
"estimatedDeliveryPlaceholder": "Estimated delivery time (e.g. 2-3 business days)",
"locationTitle": "Shop location",
"noPhysicalShop": "I don't have a physical shop",
"neighbourhoodPlaceholder": "Neighbourhood (optional)",
"addressPlaceholder": "Address (optional)",
"contactTitle": "Shop support info",
"phonePlaceholder": "Phone",
"contactRequired": "Enter at least one contact method",
"responseScheduleTitle": "Response days and hours",
"days": {
"sat": "Saturday",
"sun": "Sunday",
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday"
},
"previewTitle": "Shop preview",
"submitShop": "Submit shop",
"submitSuccess": "Shop submitted for review successfully",
"mySwitcher": "My shops",
"addShop": "Add shop/product",
"noShopsYet": "You haven't registered a shop yet",
"sellerTab": "Seller",
"buyerTab": "Buyer",
"sellerOrdersEmpty": "No orders for your shop yet",
"buyerOrdersEmpty": "You haven't purchased anything yet",
"status": {
"draft": "Draft",
"pending_review": "Pending review",
"active": "Active",
"inactive": "Inactive",
"rejected": "Rejected"
},
"productNameTitle": "Product name & description",
"productNamePlaceholder": "Enter product name",
"productNameRequired": "Product name is required",
"productDescriptionPlaceholder": "Product description (optional)",
"productImagesTitle": "Product images",
"productImagesHint": "Add multiple images and pick one as the primary image",
"primaryImage": "Primary image",
"setPrimaryImage": "Set as primary",
"variantsTitle": "Price & specifications",
"colorsPlaceholder": "Colors, comma-separated (e.g. red, blue)",
"sizesPlaceholder": "Sizes, comma-separated (e.g. small, large)",
"weightsPlaceholder": "Weights, comma-separated (optional)",
"generateVariants": "Build price table",
"defaultVariant": "Default",
"pricePlaceholder": "Price (Toman)",
"stockPlaceholder": "Stock",
"variantsRequired": "Enter at least one combination with a valid price",
"saveProduct": "Save product",
"productSaved": "Product saved successfully",
"noProductsYet": "No products yet",
"priceLabel": "Price: {{amount}} Toman",
"addProduct": "Add product",
"comparisonTitle": "Compare shop prices",
"noListingsForProduct": "No shops found for this product",
"discoverTitle": "Shops",
"quantityLabel": "Quantity",
"buyNow": "Continue purchase",
"addressRequiredHint": "You need to set your address and location to place an order",
"goToLocationSettings": "Set address and location",
"checkoutTitle": "Checkout",
"itemsTotal": "Items total",
"shippingPaidAtDelivery": "Shipping ({{method}}) — paid on delivery",
"payOnlineTotal": "Amount payable online",
"proceedToPayment": "Pay",
"paymentSuccessTitle": "Payment successful",
"paymentSuccessHint": "Your order has been placed and is being prepared",
"viewOrder": "View order",
"paymentFailedTitle": "Payment failed",
"paymentFailedHint": "Your payment encountered an error. Please try again",
"retryPayment": "Retry payment",
"orderStatus": {
"pending_payment": "Pending payment",
"on_hold": "On hold",
"failed": "Failed",
"processing": "Processing",
"shipped": "Shipped",
"ready_for_pickup": "Ready for pickup",
"completed": "Completed",
"cancelled": "Cancelled",
"refunded": "Refunded"
},
"orderDetailTitle": "Order details",
"buyerInfoTitle": "Buyer info",
"printLabelA5": "Print A5 label",
"printLabelA6": "Print A6 label",
"printInvoice": "Print invoice",
"trackingCodeLabel": "Tracking code",
"changeStatusLabel": "Change status",
"trackingSaved": "Tracking code saved",
"trackingRequiredForShipping": "A tracking code is required for this shipping method",
"statusUpdated": "Order status updated",
"invoiceTitle": "Invoice",
"confirmReceipt": "I received the item",
"receiptConfirmed": "Receipt confirmed",
"rateShop": "Rate & review",
"ratingCommentPlaceholder": "Your review of the shop (optional)",
"ratingSubmitted": "Your review was submitted",
"reportProblem": "Report a problem",
"reportPlaceholder": "Describe the problem",
"submitReport": "Submit report",
"reportSubmitted": "Problem reported successfully",
"requestReturn": "Request return",
"returnReasonPlaceholder": "Enter the reason for the return",
"submitReturn": "Submit return request",
"returnRequestSubmitted": "Return request submitted",
"withdrawalRequested": "Withdrawal request submitted successfully",
"totalBalance": "Total wallet balance",
"walletShopTab": "Shop",
"walletGiftTab": "Gift",
"walletChargedTab": "Charged",
"availableToWithdraw": "Available to withdraw",
"pendingConfirmation": "Pending confirmation",
"withdrawAmountPlaceholder": "Amount (Toman)",
"requestWithdrawal": "Request withdrawal",
"giftBalance": "Gift balance",
"giftMinWithdrawalHint": "Balance must be at least {{amount}} Toman to request a withdrawal",
"chargedBalance": "Charged balance",
"chargeBonusHint": "Charging your wallet gives you a 5% bonus gift",
"chargeAmountPlaceholder": "Charge amount (Toman)",
"chargeWallet": "Charge wallet"
}
}

View File

@@ -74,6 +74,8 @@
"billboards": "بیلبورد",
"academy": "آموزشگاه",
"offers": "درخواست ها",
"shop": "فروشگاه",
"wallet": "کیف پول",
"favorites": "علاقمندی‌ها",
"chats": "پیامها",
"notifications": "اعلانات",
@@ -206,6 +208,7 @@
"types": {
"offer": "درخواست همکاری",
"advertising": "تبلیغات",
"shop_order": "خرید از فروشگاه",
"other": "سایر"
},
"statuses": {
@@ -256,6 +259,7 @@
"username": "نام کاربری",
"password": "رمز عبور",
"googleAccount": "حساب گوگل",
"instagramImport": "وارد کردن از اینستاگرام",
"twoFactor": "ورود دو مرحله‌ای",
"avatar": "تصویر پروفایل",
"authentication": "احراز هویت",
@@ -266,7 +270,7 @@
"license": "مجوز",
"cooperationType": "نوع همکاری",
"publicRelations": "روابط عمومی",
"shaba": "شبا",
"shaba": "اطلاعات هویتی",
"location": "لوکیشن",
"bio": "بیو"
},
@@ -287,6 +291,17 @@
"linkedHint": "حساب گوگل شما متصل است:",
"unlinkedHint": "می‌توانید حساب گوگل خود را به این حساب متصل کنید و علاوه بر موبایل، با گوگل هم وارد شوید."
},
"instagramImport": {
"linkButton": "اتصال حساب اینستاگرام",
"orLinkWith": "یا اتصال با",
"linkSuccess": "حساب اینستاگرام با موفقیت متصل شد",
"loadError": "خطا در دریافت وضعیت حساب اینستاگرام",
"linkedHint": "حساب اینستاگرام شما متصل است:",
"unlinkedHint": "فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند. پس از اتصال می‌توانید بیو، پست‌ها، ریلزها و عکس پروفایل خود را از اینستاگرام وارد کنید.",
"disconnectButton": "قطع اتصال اینستاگرام",
"disconnectSuccess": "اتصال اینستاگرام قطع شد",
"disconnectError": "خطا در قطع اتصال اینستاگرام"
},
"twoFactor": {
"loadError": "خطا در دریافت وضعیت ورود دو مرحله‌ای",
"setupError": "خطا در راه‌اندازی ورود دو مرحله‌ای",
@@ -321,11 +336,21 @@
"sizeRequired": "انتخاب سایز الزامی است"
},
"shaba": {
"forPayout": "جهت واریز حق الزحمه",
"forPayout": "برای دریافت هدیه و مبلغ دریافتی از مدستاگرام و خریداران و دریافت هدیه از کاربران نیازمند تایید اطلاعات هویتی شما هستیم",
"placeholder": "شماره شبا",
"mustBeOwn": ماره شبا باید به نام خود شخص باشد",
"mustBeOwn": با و کد ملی باید به اسم یک نفر باشند",
"required": "شماره شبا الزامی است",
"invalid": "شماره شبا باید ۲۴ رقم باشد"
"invalid": "شماره شبا معتبر نیست",
"bankNameUnknown": "بانک نامشخص",
"nationalCodeInvalid": "کد ملی معتبر نیست",
"verifyIdentityButton": "احراز هویت",
"verifyIdentityRejectedHint": "مدارک قبلی شما رد شده است؛ لطفاً دوباره ارسال کنید",
"verifyIdentityPending": "در انتظار بررسی",
"verifyIdentityDone": "هویت شما تایید شده",
"saveButton": "تایید",
"saveSuccess": "اطلاعات با موفقیت ذخیره شد",
"saveError": "خطا در ذخیره اطلاعات",
"nationalCodeDuplicate": "این کد ملی قبلاً برای حساب دیگری ثبت شده است"
},
"services": {
"minOne": "لطفاً حداقل یک خدمت وارد کنید."
@@ -491,6 +516,14 @@
"googleRegister": "ثبت‌نام با گوگل",
"orLoginWith": "یا ورود با",
"orRegisterWith": "یا ثبت‌نام با",
"instagramLogin": "ورود با اینستاگرام",
"instagramRegister": "ثبت‌نام با اینستاگرام",
"orLoginWithInstagram": "یا ورود با اینستاگرام",
"orRegisterWithInstagram": "یا ثبت‌نام با اینستاگرام",
"instagramConnecting": "در حال اتصال به اینستاگرام...",
"instagramLoginFailed": "ورود با اینستاگرام ناموفق بود",
"instagramAccountTypeError": "فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند.",
"usernameSuggestedFromInstagram": "این نام کاربری از اینستاگرام پیشنهاد شده؛ می‌توانید تغییرش دهید",
"twoFactorTitle": "ورود دو مرحله‌ای",
"twoFactorHint": "کد ۶ رقمی Google Authenticator را وارد کنید",
"mobileInvalid": "شماره موبایل معتبر نیست",
@@ -1542,6 +1575,7 @@
"searchUser": "جستجوی کاربر",
"filter": "فیلتر",
"explore": "اکسپلور",
"shop": "فروشگاه",
"newProject": "ثبت پروژه",
"search": "جستجو"
}
@@ -2134,5 +2168,160 @@
"defaultCategory": "دسته‌بندی",
"defaultTeacher": "مدرس",
"priceToman": "{{price}} تومان"
},
"shops": {
"loadingCategories": "در حال بارگذاری دسته‌بندی‌ها...",
"selectSubCategory": "زیردسته‌های خود را انتخاب کنید",
"subCategoryDisplayHint": "تیک کنار هر مورد، نمایش آن در صفحه فروشگاه است",
"displaySubCategoryAria": "نمایش {{name}} در فروشگاه",
"unknownError": "خطای نامشخصی رخ داد",
"saveAndContinue": "ثبت و ادامه",
"nameTitle": "نام فروشگاه",
"namePlaceholder": "نام فروشگاه را وارد کنید",
"nameRequired": "نام فروشگاه الزامی است",
"logoTitle": "لوگوی فروشگاه",
"selectLogo": "انتخاب/ویرایش لوگو",
"logoRequired": "لطفاً لوگوی فروشگاه را انتخاب کنید",
"categoryTitle": "دسته‌بندی فروشگاه",
"categoryRequired": "انتخاب دسته‌بندی، زیردسته و مورد نمایشی الزامی است",
"shippingTitle": "روش‌های ارسال",
"shippingMethodRequired": "حداقل یک روش ارسال را انتخاب کنید",
"shippingMethods": {
"post": "پست",
"tipax": "تیپاکس",
"chapar": "چاپار",
"intercity_freight": "باربری درون‌شهری",
"courier": "پیک",
"own_vehicle": "خودروی فروشگاه"
},
"shippingCostPlaceholder": "هزینه ارسال (تومان)",
"codAvailable": "امکان پرداخت در محل",
"sameDayAvailable": "امکان تحویل فوری در همان روز",
"freeShippingThresholdPlaceholder": "حداقل سفارش برای ارسال رایگان (تومان)",
"estimatedDeliveryPlaceholder": "زمان تقریبی ارسال (مثلاً ۲ تا ۳ روز کاری)",
"locationTitle": "موقعیت فروشگاه",
"noPhysicalShop": "فروشگاه حضوری ندارم",
"neighbourhoodPlaceholder": "محله (اختیاری)",
"addressPlaceholder": "آدرس (اختیاری)",
"contactTitle": "اطلاعات پشتیبانی فروشگاه",
"phonePlaceholder": "تلفن",
"contactRequired": "حداقل یک راه ارتباطی وارد کنید",
"responseScheduleTitle": "روزها و ساعات پاسخگویی",
"days": {
"sat": "شنبه",
"sun": "یکشنبه",
"mon": "دوشنبه",
"tue": "سه‌شنبه",
"wed": "چهارشنبه",
"thu": "پنجشنبه",
"fri": "جمعه"
},
"previewTitle": "پیش‌نمایش فروشگاه",
"submitShop": "ثبت فروشگاه",
"submitSuccess": "فروشگاه با موفقیت برای بررسی ارسال شد",
"mySwitcher": "فروشگاه‌های من",
"addShop": "افزودن فروشگاه/کالا",
"noShopsYet": "هنوز فروشگاهی ثبت نکرده‌اید",
"sellerTab": "فروشنده",
"buyerTab": "خریدار",
"sellerOrdersEmpty": "هنوز سفارشی برای فروشگاه شما ثبت نشده است",
"buyerOrdersEmpty": "هنوز خریدی ثبت نکرده‌اید",
"status": {
"draft": "پیش‌نویس",
"pending_review": "در انتظار بررسی",
"active": "فعال",
"inactive": "غیرفعال",
"rejected": "رد شده"
},
"productNameTitle": "نام و توضیحات کالا",
"productNamePlaceholder": "نام کالا را وارد کنید",
"productNameRequired": "نام کالا الزامی است",
"productDescriptionPlaceholder": "توضیحات کالا (اختیاری)",
"productImagesTitle": "تصاویر کالا",
"productImagesHint": "می‌توانید چند تصویر اضافه کنید و یکی را به‌عنوان تصویر اصلی انتخاب کنید",
"primaryImage": "تصویر اصلی",
"setPrimaryImage": "انتخاب به‌عنوان اصلی",
"variantsTitle": "قیمت و مشخصات کالا",
"colorsPlaceholder": "رنگ‌ها را با کاما جدا کنید (مثلاً قرمز, آبی)",
"sizesPlaceholder": "سایزها را با کاما جدا کنید (مثلاً کوچک, بزرگ)",
"weightsPlaceholder": "وزن‌ها را با کاما جدا کنید (اختیاری)",
"generateVariants": "ساخت جدول قیمت",
"defaultVariant": "پیش‌فرض",
"pricePlaceholder": "قیمت (تومان)",
"stockPlaceholder": "موجودی",
"variantsRequired": "حداقل یک ترکیب با قیمت معتبر وارد کنید",
"saveProduct": "ثبت کالا",
"productSaved": "کالا با موفقیت ثبت شد",
"noProductsYet": "هنوز کالایی ثبت نشده است",
"priceLabel": "قیمت: {{amount}} تومان",
"addProduct": "افزودن کالا",
"comparisonTitle": "مقایسه قیمت فروشگاه‌ها",
"noListingsForProduct": "فروشگاهی برای این کالا یافت نشد",
"discoverTitle": "فروشگاه‌ها",
"quantityLabel": "تعداد",
"buyNow": "ادامه خرید",
"addressRequiredHint": "برای ثبت سفارش نیاز به ثبت آدرس و لوکیشن می‌باشید",
"goToLocationSettings": "ثبت آدرس و لوکیشن",
"checkoutTitle": "تکمیل خرید",
"itemsTotal": "جمع کالاها",
"shippingPaidAtDelivery": "هزینه ارسال ({{method}}) — پرداخت زمان تحویل",
"payOnlineTotal": "مبلغ قابل پرداخت آنلاین",
"proceedToPayment": "پرداخت",
"paymentSuccessTitle": "پرداخت با موفقیت انجام شد",
"paymentSuccessHint": "سفارش شما ثبت شد و در حال آماده‌سازی است",
"viewOrder": "مشاهده سفارش",
"paymentFailedTitle": "پرداخت ناموفق بود",
"paymentFailedHint": "پرداخت شما با خطا مواجه شد. لطفاً دوباره تلاش کنید",
"retryPayment": "تلاش مجدد",
"orderStatus": {
"pending_payment": "در انتظار پرداخت",
"on_hold": "در انتظار بررسی",
"failed": "ناموفق",
"processing": "در حال انجام",
"shipped": "ارسال شده",
"ready_for_pickup": "آماده تحویل",
"completed": "تکمیل شده",
"cancelled": "لغو شده",
"refunded": "مسترد شده"
},
"orderDetailTitle": "جزئیات سفارش",
"buyerInfoTitle": "اطلاعات خریدار",
"printLabelA5": "چاپ برچسب A5",
"printLabelA6": "چاپ برچسب A6",
"printInvoice": "چاپ فاکتور",
"trackingCodeLabel": "کد رهگیری",
"changeStatusLabel": "تغییر وضعیت",
"trackingSaved": "کد رهگیری ثبت شد",
"trackingRequiredForShipping": "برای این روش ارسال، ثبت کد رهگیری الزامی است",
"statusUpdated": "وضعیت سفارش به‌روزرسانی شد",
"invoiceTitle": "فاکتور خرید",
"confirmReceipt": "کالا را دریافت کردم",
"receiptConfirmed": "دریافت کالا ثبت شد",
"rateShop": "ثبت نظر و امتیاز",
"ratingCommentPlaceholder": "نظر شما درباره فروشگاه (اختیاری)",
"ratingSubmitted": "نظر شما با موفقیت ثبت شد",
"reportProblem": "ثبت مشکل",
"reportPlaceholder": "مشکل خود را توضیح دهید",
"submitReport": "ارسال گزارش مشکل",
"reportSubmitted": "مشکل با موفقیت ثبت شد",
"requestReturn": "درخواست مرجوعی",
"returnReasonPlaceholder": "دلیل درخواست مرجوعی را وارد کنید",
"submitReturn": "ثبت درخواست مرجوعی",
"returnRequestSubmitted": "درخواست مرجوعی با موفقیت ثبت شد",
"withdrawalRequested": "درخواست تسویه با موفقیت ثبت شد",
"totalBalance": "موجودی کل کیف پول",
"walletShopTab": "فروشگاه",
"walletGiftTab": "هدیه",
"walletChargedTab": "شارژ شده",
"availableToWithdraw": "قابل تسویه",
"pendingConfirmation": "در انتظار تایید",
"withdrawAmountPlaceholder": "مبلغ (تومان)",
"requestWithdrawal": "درخواست تسویه",
"giftBalance": "موجودی هدیه",
"giftMinWithdrawalHint": "برای درخواست تسویه، موجودی باید حداقل {{amount}} تومان باشد",
"chargedBalance": "موجودی شارژ شده",
"chargeBonusHint": "با شارژ کیف پول، ۵٪ هدیه اضافه دریافت می‌کنید",
"chargeAmountPlaceholder": "مبلغ شارژ (تومان)",
"chargeWallet": "شارژ کیف پول"
}
}

View File

@@ -10,7 +10,7 @@ export interface IVerifyOtp {
step?: string;
page?: "home" | "auth-page";
id?: string;
auth_provider?: "mobile" | "google";
auth_provider?: "mobile" | "google" | "instagram";
email?: string | null;
requires_2fa?: boolean;
temp_token?: string;
@@ -83,6 +83,41 @@ export interface ISubExpertise {
name: string;
_id: string;
}
export interface IShopCategory {
_id: string;
category: string;
sub_categories: ISubShopCategory[];
}
export interface ISubShopCategory {
name: string;
_id: string;
}
export interface IShopProductVariant {
_id: string;
color?: string | null;
size?: string | null;
weight?: string | null;
price: number;
discount_price?: number | null;
stock: number;
sku?: string | null;
}
export interface IShopProductListing {
_id: string;
shop: string | { _id: string; name: string; logo?: string | null };
catalogProduct: string | { _id: string; name: string; description?: string | null };
title: string;
description?: string | null;
images: string[];
primaryImageIndex: number;
variants: IShopProductVariant[];
status: "pending_shop_approval" | "active" | "inactive";
createdAt?: string;
}
export interface LastPost {
_id: string;
post_image: string;
@@ -212,12 +247,13 @@ export interface User {
successfulProjectsCount?: number | undefined;
mobile?: string;
email?: string;
auth_provider?: "mobile" | "google";
auth_provider?: "mobile" | "google" | "instagram";
isRegister?: boolean;
city?: ICity;
province?: IProvince;
shaba?: string | null;
national_code?: string | null;
birthday?: string | null;
cooperation_abroad?: boolean | null | undefined;
conversation_projects?: boolean | null | undefined;
posts?: Post[];