shop
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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")}{" "}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
142
src/app/auth/instagram/callback/page.tsx
Normal file
142
src/app/auth/instagram/callback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/app/settings/edit/instagram-import/page.tsx
Normal file
110
src/app/settings/edit/instagram-import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
191
src/app/settings/shop/page.tsx
Normal file
191
src/app/settings/shop/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/app/settings/wallet/page.tsx
Normal file
218
src/app/settings/wallet/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/app/shops/[shopId]/page.tsx
Normal file
74
src/app/shops/[shopId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/app/shops/[shopId]/products/new/images/page.tsx
Normal file
165
src/app/shops/[shopId]/products/new/images/page.tsx
Normal 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;
|
||||
148
src/app/shops/[shopId]/products/new/name/page.tsx
Normal file
148
src/app/shops/[shopId]/products/new/name/page.tsx
Normal 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;
|
||||
194
src/app/shops/[shopId]/products/new/variants/page.tsx
Normal file
194
src/app/shops/[shopId]/products/new/variants/page.tsx
Normal 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;
|
||||
144
src/app/shops/checkout/[orderId]/page.tsx
Normal file
144
src/app/shops/checkout/[orderId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/shops/discover/page.tsx
Normal file
36
src/app/shops/discover/page.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
220
src/app/shops/listing/[listingId]/page.tsx
Normal file
220
src/app/shops/listing/[listingId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/app/shops/new/category/page.tsx
Normal file
104
src/app/shops/new/category/page.tsx
Normal 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;
|
||||
213
src/app/shops/new/contact/page.tsx
Normal file
213
src/app/shops/new/contact/page.tsx
Normal 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;
|
||||
189
src/app/shops/new/location/page.tsx
Normal file
189
src/app/shops/new/location/page.tsx
Normal 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;
|
||||
112
src/app/shops/new/logo/page.tsx
Normal file
112
src/app/shops/new/logo/page.tsx
Normal 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;
|
||||
86
src/app/shops/new/name/page.tsx
Normal file
86
src/app/shops/new/name/page.tsx
Normal 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;
|
||||
115
src/app/shops/new/preview/page.tsx
Normal file
115
src/app/shops/new/preview/page.tsx
Normal 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;
|
||||
182
src/app/shops/new/shipping/page.tsx
Normal file
182
src/app/shops/new/shipping/page.tsx
Normal 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;
|
||||
468
src/app/shops/orders/[orderId]/page.tsx
Normal file
468
src/app/shops/orders/[orderId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/app/shops/payment/failed/page.tsx
Normal file
63
src/app/shops/payment/failed/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/shops/payment/success/page.tsx
Normal file
46
src/app/shops/payment/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/app/shops/product/[catalogId]/page.tsx
Normal file
105
src/app/shops/product/[catalogId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/auth/InstagramSignInButton.tsx
Normal file
91
src/components/auth/InstagramSignInButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
145
src/components/shops/ShopCategoryPicker.tsx
Normal file
145
src/components/shops/ShopCategoryPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/shops/ShopProductAuthor.tsx
Normal file
31
src/components/shops/ShopProductAuthor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/shops/ShopProductGrid.tsx
Normal file
134
src/components/shops/ShopProductGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/shops/orders/BuyerOrderItem.tsx
Normal file
80
src/components/shops/orders/BuyerOrderItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/shops/orders/SellerOrderItem.tsx
Normal file
87
src/components/shops/orders/SellerOrderItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/config/instagramAuth.ts
Normal file
41
src/config/instagramAuth.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -37,6 +37,7 @@ const isPublicAuthRequest = (url: string): boolean => {
|
||||
/^\/register\/?$/,
|
||||
/^\/register\/verify$/,
|
||||
/^\/auth\/google$/,
|
||||
/^\/auth\/instagram\/callback$/,
|
||||
];
|
||||
return publicPatterns.some((pattern) => pattern.test(path));
|
||||
};
|
||||
|
||||
39
src/hooks/useProductWizardId.ts
Normal file
39
src/hooks/useProductWizardId.ts
Normal 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;
|
||||
}
|
||||
26
src/hooks/useShopWizardId.ts
Normal file
26
src/hooks/useShopWizardId.ts
Normal 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;
|
||||
}
|
||||
10
src/lib/auth/instagramClientId.ts
Normal file
10
src/lib/auth/instagramClientId.ts
Normal 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();
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
15
src/lib/iranNationalCode.ts
Normal file
15
src/lib/iranNationalCode.ts
Normal 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];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "شارژ کیف پول"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user