diff --git a/modstagram-next.zip b/modstagram-next.zip deleted file mode 100644 index 79689cd..0000000 Binary files a/modstagram-next.zip and /dev/null differ diff --git a/src/app/(auth)/(login)/login/page.tsx b/src/app/(auth)/(login)/login/page.tsx index 4a493ef..e4d885f 100644 --- a/src/app/(auth)/(login)/login/page.tsx +++ b/src/app/(auth)/(login)/login/page.tsx @@ -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() { + {t("auth.loginWithUsername")} diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index f94b4f2..0ac2301 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -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() { + {t("auth.haveAccount")}{" "} diff --git a/src/app/(auth)/(register)/register/username/page.tsx b/src/app/(auth)/(register)/register/username/page.tsx index a478ac5..77ef673 100644 --- a/src/app/(auth)/(register)/register/username/page.tsx +++ b/src/app/(auth)/(register)/register/username/page.tsx @@ -32,6 +32,11 @@ function UsernamePage() { const [suggestions, setSuggestions] = useState([]); 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 && ( + + {t("auth.usernameSuggestedFromInstagram")} + + )} + {isDuplicate && ( {t("auth.usernameDuplicate")} diff --git a/src/app/(auth)/AuthProviders.tsx b/src/app/(auth)/AuthProviders.tsx index 7e82c7f..1610cba 100644 --- a/src/app/(auth)/AuthProviders.tsx +++ b/src/app/(auth)/AuthProviders.tsx @@ -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, "clientId"> ) { @@ -25,30 +32,53 @@ export function GoogleSignInButton( ); } +export function InstagramSignInButton( + props: Omit, "clientId"> +) { + const clientId = useInstagramClientId(); + return ( + + ); +} + 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 ( - - {children} - + + + {children} + + ); } diff --git a/src/app/api/auth/config/route.ts b/src/app/api/auth/config/route.ts index b671f67..421d75a 100644 --- a/src/app/api/auth/config/route.ts +++ b/src/app/api/auth/config/route.ts @@ -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(), }); } diff --git a/src/app/auth/instagram/callback/page.tsx b/src/app/auth/instagram/callback/page.tsx new file mode 100644 index 0000000..dbcecd4 --- /dev/null +++ b/src/app/auth/instagram/callback/page.tsx @@ -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; + + 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 ( + +
+

+ {status === "working" + ? t("auth.instagramConnecting") + : t("auth.instagramLoginFailed")} +

+
+
+ ); +} diff --git a/src/app/settings/edit/instagram-import/page.tsx b/src/app/settings/edit/instagram-import/page.tsx new file mode 100644 index 0000000..9ee11d3 --- /dev/null +++ b/src/app/settings/edit/instagram-import/page.tsx @@ -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(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 ( + + + {t("settings.edit.nav.instagramImport")} +
+ {loading ? ( +

{t("common.loading")}

+ ) : status?.connected ? ( + <> +

+ {t("settings.edit.instagramImport.linkedHint")} +

+ {status.instagram_username ? ( +

+ @{status.instagram_username} +

+ ) : null} + + + ) : ( + <> +

+ {t("settings.edit.instagramImport.unlinkedHint")} +

+ + + )} +
+
+
+ ); +} diff --git a/src/app/settings/edit/page.tsx b/src/app/settings/edit/page.tsx index 4f2fa36..042022b 100644 --- a/src/app/settings/edit/page.tsx +++ b/src/app/settings/edit/page.tsx @@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record = { "/username": "username", "/password": "password", "/google-account": "googleAccount", + "/instagram-import": "instagramImport", "/two-factor": "twoFactor", "/avatar": "avatar", "/Authentication": "authentication", diff --git a/src/app/settings/edit/shaba/page.tsx b/src/app/settings/edit/shaba/page.tsx index 231d1ae..e3450e2 100644 --- a/src/app/settings/edit/shaba/page.tsx +++ b/src/app/settings/edit/shaba/page.tsx @@ -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 ( - - -
- - {t("settings.edit.nav.shaba")} - -
- {t("settings.edit.shaba.forPayout")} -
- IR - - {formik.touched.shaba && formik.errors.shaba && ( - - {formik.errors.shaba} - - )} -
- {t("settings.edit.shaba.mustBeOwn")} - - {t("settings.edit.save")} - -
-
-
-
- ); -} - -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 = {}; + 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 ( + + +
+ + {t("settings.edit.nav.shaba")} + +
+ + {t("settings.edit.shaba.forPayout")} + + +
+
+ +
+
+ { + 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" + }`} + /> + +
+
+ + {formik.touched.nationalCode && formik.errors.nationalCode && ( + + {formik.errors.nationalCode} + + )} + {formik.touched.birthDate && formik.errors.birthDate && ( + + {formik.errors.birthDate} + + )} + +
+ IR + + {bankName && ( + + {bankName} + + )} + {formik.touched.shaba && formik.errors.shaba && ( + + {formik.errors.shaba} + + )} +
+ + + {t("settings.edit.shaba.mustBeOwn")} + + + router.push("/settings/edit/Authentication")} + > + {verifyLabel} + + {isVerified === "rejected" && ( + + {t("settings.edit.shaba.verifyIdentityRejectedHint")} + + )} + + + {t("settings.edit.shaba.saveButton")} + +
+
+
+
+ ); +} + +export default ShabaPage; diff --git a/src/app/settings/financial/page.tsx b/src/app/settings/financial/page.tsx index ff2482b..d5fed2c 100644 --- a/src/app/settings/financial/page.tsx +++ b/src/app/settings/financial/page.tsx @@ -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"); }; diff --git a/src/app/settings/shop/page.tsx b/src/app/settings/shop/page.tsx new file mode 100644 index 0000000..01e3cd8 --- /dev/null +++ b/src/app/settings/shop/page.tsx @@ -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(FILTER_SELLER); + const [myShops, setMyShops] = useState(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 ( + + +
+ + {t("settings.nav.shop")} + +
+ + {showSwitcher && ( +
+ {myShops === null ? ( +

+ {t("common.loading")} +

+ ) : myShops.length === 0 ? ( +

+ {t("shops.noShopsYet")} +

+ ) : ( + myShops.map((shop) => ( +
+ {shop.logo && ( + // eslint-disable-next-line @next/next/no-img-element + {shop.name} + )} + {shop.name} + + {t(`shops.status.${shop.status}`)} + +
+ )) + )} +
+ )} + +
+ +
+ setFilter(FILTER_SELLER)} + > + {t("shops.sellerTab")} + + setFilter(FILTER_BUYER)} + > + {t("shops.buyerTab")} + +
+ +
+ {filter === FILTER_SELLER ? ( + sellerOrdersLoading ? ( +

{t("common.loading")}

+ ) : sellerOrders.length === 0 ? ( +

+ {t("shops.sellerOrdersEmpty")} +

+ ) : ( + sellerOrders.map((order) => ( + + )) + ) + ) : buyerOrdersLoading ? ( +

{t("common.loading")}

+ ) : buyerOrders.length === 0 ? ( +

+ {t("shops.buyerOrdersEmpty")} +

+ ) : ( + buyerOrders.map((order) => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/src/app/settings/wallet/page.tsx b/src/app/settings/wallet/page.tsx new file mode 100644 index 0000000..e1bac09 --- /dev/null +++ b/src/app/settings/wallet/page.tsx @@ -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(BUCKET_SHOP); + const [summary, setSummary] = useState(null); + const [withdrawAmount, setWithdrawAmount] = useState(""); + const [chargeAmount, setChargeAmount] = useState(""); + + const loadSummary = () => { + request("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 ( + + + {t("settings.nav.wallet")} + +
+

{t("shops.totalBalance")}

+

+ {totalBalance.toLocaleString()} {t("settings.toman")} +

+
+ +
+ setBucket(BUCKET_SHOP)} + > + {t("shops.walletShopTab")} + + setBucket(BUCKET_GIFT)} + > + {t("shops.walletGiftTab")} + + setBucket(BUCKET_CHARGED)} + > + {t("shops.walletChargedTab")} + +
+ +
+ {bucket === BUCKET_SHOP && summary && ( + <> +
+ {t("shops.availableToWithdraw")} + + {summary.shop.available.toLocaleString()} {t("settings.toman")} + +
+
+ {t("shops.pendingConfirmation")} + + {summary.shop.pending.toLocaleString()} {t("settings.toman")} + +
+
+ setWithdrawAmount(e.target.value)} + /> + + {t("shops.requestWithdrawal")} + +
+ + )} + + {bucket === BUCKET_GIFT && summary && ( + <> +
+ {t("shops.giftBalance")} + + {summary.gift.available.toLocaleString()} {t("settings.toman")} + +
+

+ {t("shops.giftMinWithdrawalHint", { + amount: summary.gift.minWithdrawal.toLocaleString(), + })} +

+
+ setWithdrawAmount(e.target.value)} + /> + + {t("shops.requestWithdrawal")} + +
+ + )} + + {bucket === BUCKET_CHARGED && summary && ( + <> +
+ {t("shops.chargedBalance")} + + {summary.charged.available.toLocaleString()} {t("settings.toman")} + +
+

{t("shops.chargeBonusHint")}

+
+ setChargeAmount(e.target.value)} + /> + + {t("shops.chargeWallet")} + +
+ + )} +
+
+
+ ); +} diff --git a/src/app/shops/[shopId]/page.tsx b/src/app/shops/[shopId]/page.tsx new file mode 100644 index 0000000..690abe1 --- /dev/null +++ b/src/app/shops/[shopId]/page.tsx @@ -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(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 ( + + +
+ {shop?.name || t("settings.nav.shop")} + + + +
+ + +
+
+ ); +} diff --git a/src/app/shops/[shopId]/products/new/images/page.tsx b/src/app/shops/[shopId]/products/new/images/page.tsx new file mode 100644 index 0000000..85eff0e --- /dev/null +++ b/src/app/shops/[shopId]/products/new/images/page.tsx @@ -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([]); + const [primaryIndex, setPrimaryIndex] = useState(0); + + const handleSelect = (event: React.ChangeEvent) => { + 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 ( + + + + {t("shops.productImagesTitle")} +

+ {t("shops.productImagesHint")} +

+ +
+ {images.map((img, index) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + + +
+ ))} + + + +
+
+ + + {t("shops.saveAndContinue")} + + +
+
+ ); +} + +export default ProductImagesPage; diff --git a/src/app/shops/[shopId]/products/new/name/page.tsx b/src/app/shops/[shopId]/products/new/name/page.tsx new file mode 100644 index 0000000..e2cf159 --- /dev/null +++ b/src/app/shops/[shopId]/products/new/name/page.tsx @@ -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(null); + const [suggestions, setSuggestions] = useState([]); + const debounceRef = useRef | 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 ( + + + + {t("shops.productNameTitle")} + +
+ handleNameChange(e.target.value)} + /> + {suggestions.length > 0 && ( +
+ {suggestions.map((product) => ( + + ))} +
+ )} +
+ +