diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index c4920f5..ee78fd2 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -14,9 +14,9 @@ module.exports = { NODE_ENV: "production", PORT: "3004", HOSTNAME: "0.0.0.0", - UPSTREAM_API_URL: "https://api.modstagram.ir", - NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir", - NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage", + UPSTREAM_API_URL: "https://api.modstagram.com", + NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.com", + NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.com/storage", NEXT_PUBLIC_BASE_URL: "/api/v1", NEXT_PUBLIC_SITE_URL: "https://modstagram.com", GOOGLE_CLIENT_ID: diff --git a/next.config.ts b/next.config.ts index 498178a..78d4dda 100644 --- a/next.config.ts +++ b/next.config.ts @@ -35,6 +35,24 @@ const nextConfig = { port: '', pathname: '/**', }, + { + protocol: 'https', + hostname: 'app.modstagram.com', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'panel.modstagram.com', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'cdn.modstagram.com', + port: '', + pathname: '/**', + }, { protocol: 'https', hostname: 'api.qrserver.com', @@ -54,7 +72,31 @@ const nextConfig = { minimumCacheTTL: 31536000, }, - // ۳. فقط وبلاگ — API/storage از Route Handler پروکسی می‌شوند + // ۳. ریدایرکت دائمی دامنه قدیمی .ir به دامنه اصلی .com + async redirects() { + return [ + { + source: '/:path*', + has: [{ type: 'host', value: 'modstagram.ir' }], + destination: 'https://modstagram.com/:path*', + permanent: true, + }, + { + source: '/:path*', + has: [{ type: 'host', value: 'www.modstagram.ir' }], + destination: 'https://modstagram.com/:path*', + permanent: true, + }, + { + source: '/:path*', + has: [{ type: 'host', value: 'app.modstagram.ir' }], + destination: 'https://modstagram.com/:path*', + permanent: true, + }, + ]; + }, + + // ۴. فقط وبلاگ — API/storage از Route Handler پروکسی می‌شوند async rewrites() { return [ { @@ -67,7 +109,7 @@ const nextConfig = { }, ]; }, - // ۴. تنظیمات هدرها + // ۵. تنظیمات هدرها async headers() { return [ { diff --git a/scripts/server-fix-502.sh b/scripts/server-fix-502.sh index 90a50ae..9761537 100644 --- a/scripts/server-fix-502.sh +++ b/scripts/server-fix-502.sh @@ -30,9 +30,9 @@ module.exports = { NODE_ENV: "production", PORT: "3004", HOSTNAME: "0.0.0.0", - UPSTREAM_API_URL: "https://api.modstagram.ir", - NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir", - NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage", + UPSTREAM_API_URL: "https://api.modstagram.com", + NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.com", + NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.com/storage", NEXT_PUBLIC_BASE_URL: "/api/v1", NEXT_PUBLIC_SITE_URL: "https://modstagram.com", }, @@ -45,9 +45,9 @@ if [ ! -f .env.production ]; then cat > .env.production << 'ENVEOF' PORT=3004 HOSTNAME=0.0.0.0 -UPSTREAM_API_URL=https://api.modstagram.ir -NEXT_PUBLIC_SOCKET_URL=https://api.modstagram.ir -NEXT_PUBLIC_IMAGE_BASE_URL=https://api.modstagram.ir/storage +UPSTREAM_API_URL=https://api.modstagram.com +NEXT_PUBLIC_SOCKET_URL=https://api.modstagram.com +NEXT_PUBLIC_IMAGE_BASE_URL=https://api.modstagram.com/storage NEXT_PUBLIC_BASE_URL=/api/v1 NEXT_PUBLIC_SITE_URL=https://modstagram.com ENVEOF diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx index ae02b7d..f0ed792 100644 --- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx +++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx @@ -637,7 +637,7 @@ useEffect(() => { onKeyDown={preventDownload} disablePictureInPicture > - + {t("academy.course.videoUnsupported")} diff --git a/src/app/booking/[userId]/page.tsx b/src/app/booking/[userId]/page.tsx new file mode 100644 index 0000000..8a1a073 --- /dev/null +++ b/src/app/booking/[userId]/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import RoundedButton from "@/components/elements/RoundedButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import PageTitle from "@/components/settings/PageTitle"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; +import useAxios from "@/hooks/useAxios"; +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 BookingService = { + serviceId: string; + title: string; + price: number; + image?: string | null; +}; + +type BookingConfig = { + _id: string; + services: BookingService[]; + weeklySchedule: { day: string; ranges: { start_time: string; end_time: string }[] }[]; + holidayDates: string[]; + depositRequired: boolean; +}; + +type TimeSlot = { start_time: string; end_time: string }; + +const DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +function buildUpcomingDates(config: BookingConfig, count = 21) { + const openDays = new Set(config.weeklySchedule.map((entry) => entry.day)); + const holidaySet = new Set(config.holidayDates.map((d) => d.slice(0, 10))); + const dates: string[] = []; + const cursor = new Date(); + + for (let i = 0; i < 60 && dates.length < count; i++) { + const date = new Date(cursor); + date.setDate(cursor.getDate() + i); + const iso = date.toISOString().slice(0, 10); + const dayKey = DAY_KEYS[date.getDay()]; + if (openDays.has(dayKey) && !holidaySet.has(iso)) { + dates.push(iso); + } + } + return dates; +} + +function BookingFlowPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const params = useParams<{ userId: string }>(); + + const [config, setConfig] = useState(undefined); + const [step, setStep] = useState<"service" | "datetime" | "confirm">("service"); + const [selectedService, setSelectedService] = useState(null); + const [selectedDate, setSelectedDate] = useState(null); + const [slots, setSlots] = useState(null); + const [selectedSlot, setSelectedSlot] = useState(null); + + useEffect(() => { + request<{ config: BookingConfig | null }>( + "GET", + `/bookings/public/${params.userId}` + ).then((res) => setConfig(res?.config ?? null)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params.userId]); + + const upcomingDates = useMemo( + () => (config ? buildUpcomingDates(config) : []), + [config] + ); + + useEffect(() => { + if (!config || !selectedDate) return; + setSlots(null); + setSelectedSlot(null); + request<{ slots: TimeSlot[] }>( + "GET", + `/bookings/availability?configId=${config._id}&date=${selectedDate}` + ).then((res) => setSlots(res?.slots || [])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDate, config]); + + const handleConfirm = async () => { + if (!config || !selectedService || !selectedDate || !selectedSlot) return; + try { + const res = await request<{ booking: { _id: string; depositRequired: boolean } }>( + "POST", + "/bookings/book", + { + configId: config._id, + serviceId: selectedService.serviceId, + date: selectedDate, + startTime: selectedSlot.start_time, + endTime: selectedSlot.end_time, + } + ); + + const booking = res?.booking; + if (!booking) return; + + if (booking.depositRequired) { + const payRes = await request<{ paymentUrl?: string }>( + "POST", + "/bookings/payment/initiate", + { bookingId: booking._id } + ); + if (payRes?.paymentUrl) { + window.location.href = payRes.paymentUrl; + } + return; + } + + toast.success(t("booking.bookingConfirmed")); + router.push(`/users/${params.userId}`); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }; + + return ( + + + {t("booking.bookOnline")} + + {config === undefined ? ( +

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

+ ) : !config ? ( +

+ {t("booking.emptyList")} +

+ ) : ( +
+ {step === "service" && ( +
+

{t("booking.selectService")}

+ {config.services.map((service) => ( + + ))} +
+ )} + + {step === "datetime" && selectedService && ( +
+

{t("booking.selectDateTime")}

+
+ {upcomingDates.map((date) => ( + + ))} +
+ + {selectedDate && ( +
+ {slots === null ? ( +

{t("common.loading")}

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

+ {t("booking.noAvailableSlots")} +

+ ) : ( +
+ {slots.map((slot) => ( + + ))} +
+ )} +
+ )} +
+ )} + + {step === "confirm" && selectedService && selectedDate && selectedSlot && ( +
+

{t("booking.confirmBooking")}

+
+
+ {t("booking.selectService")} + {selectedService.title} +
+
+ {t("booking.selectDateTime")} + + {selectedDate} — {selectedSlot.start_time}-{selectedSlot.end_time} + +
+
+ {t("settings.toman")} + + {Number(selectedService.price).toLocaleString()} + +
+ {config.depositRequired && ( +
+ {t("booking.depositAmountLabel")} + + {Math.round(selectedService.price * 0.3).toLocaleString()}{" "} + {t("settings.toman")} + +
+ )} +
+ + {config.depositRequired + ? t("booking.payDeposit") + : t("booking.confirmBooking")} + +
+ )} +
+ )} +
+
+ ); +} + +export default BookingFlowPage; diff --git a/src/app/offer/payment-web/route.js b/src/app/offer/payment-web/route.js index 594f43e..28332c8 100644 --- a/src/app/offer/payment-web/route.js +++ b/src/app/offer/payment-web/route.js @@ -1,4 +1,6 @@ // src/app/offer/payment-web/route.js +import { UPSTREAM_API } from "@/lib/api/upstreamProxy"; + export async function GET(request) { const { searchParams } = new URL(request.url); @@ -20,7 +22,7 @@ export async function GET(request) { const offerType = searchParams.get("offerType"); const userId = searchParams.get("userId"); - const backendUrl = `https://app.modstagram.ir/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&Authority=${Authority}&Status=${Status}`; + const backendUrl = `${UPSTREAM_API}/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&Authority=${Authority}&Status=${Status}`; try { diff --git a/src/app/settings/booking/new/name/page.tsx b/src/app/settings/booking/new/name/page.tsx new file mode 100644 index 0000000..53ab0e1 --- /dev/null +++ b/src/app/settings/booking/new/name/page.tsx @@ -0,0 +1,119 @@ +"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 { + getBookingWizardId, + saveBookingWizardId, +} from "@/hooks/useBookingWizardId"; +import { useFormik } from "formik"; +import * as yup from "yup"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +function BookingNamePage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const { request, loading } = useAxios(); + const isEditMode = searchParams.get("edit") === "1"; + const [editConfigId] = useState(() => + isEditMode ? getBookingWizardId() : null + ); + + const formik = useFormik({ + initialValues: { name: "" }, + validationSchema: yup.object({ + name: yup.string().trim().required(t("booking.nameRequired")), + }), + onSubmit: async (values) => { + try { + if (editConfigId) { + router.push("/settings/booking/new/services?edit=1"); + return; + } + + const response = await request<{ configId: string }>( + "POST", + "/bookings/draft", + { name: values.name.trim() } + ); + if (response?.configId) { + saveBookingWizardId(String(response.configId)); + } + router.push("/settings/booking/new/services"); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }, + }); + + useEffect(() => { + if (!editConfigId) return; + request<{ config: { name: string } }>( + "GET", + `/bookings/${editConfigId}` + ).then((res) => { + if (!res?.config) return; + formik.setValues({ name: res.config.name || "" }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editConfigId]); + + return ( + + +
+ + + {t("booking.nameTitle")} + + + {formik.touched.name && formik.errors.name && ( + + {formik.errors.name} + + )} + + + + {t("shops.saveAndContinue")} + + +
+
+
+ ); +} + +export default BookingNamePage; diff --git a/src/app/settings/booking/new/preview/page.tsx b/src/app/settings/booking/new/preview/page.tsx new file mode 100644 index 0000000..00c227c --- /dev/null +++ b/src/app/settings/booking/new/preview/page.tsx @@ -0,0 +1,136 @@ +"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 { + clearBookingWizardId, + useBookingWizardId, +} from "@/hooks/useBookingWizardId"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +type PreviewConfig = { + name: string; + services: { title: string; price: number }[]; + weeklySchedule: { day: string; ranges: { start_time: string; end_time: string }[] }[]; + holidayDates: string[]; + depositRequired: boolean; +}; + +function BookingPreviewPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const configId = useBookingWizardId(); + const [config, setConfig] = useState(null); + + useEffect(() => { + if (!configId) return; + request<{ config: PreviewConfig }>("GET", `/bookings/${configId}`).then( + (res) => setConfig(res?.config || null) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configId]); + + const handleSubmit = async () => { + if (!configId) return; + try { + await request("POST", `/bookings/${configId}/submit`, {}); + clearBookingWizardId(); + toast.success(t("booking.submitSuccess")); + router.push("/settings/booking"); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }; + + return ( + + +
+ + + {t("booking.previewTitle")} + + + {!config ? ( +

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

+ ) : ( +
+
+

+ {t("booking.nameTitle")} +

+

{config.name}

+
+ +
+

+ {t("booking.servicesTitle")} +

+ {config.services.map((s) => ( +
+ {s.title} + + {Number(s.price).toLocaleString()} {t("settings.toman")} + +
+ ))} +
+ +
+

+ {t("booking.scheduleTitle")} +

+ {config.weeklySchedule.map((entry) => ( +
+ + {t(`shops.days.${entry.day}`)}: + {" "} + {entry.ranges + .map((r) => `${r.start_time}-${r.end_time}`) + .join("، ")} +
+ ))} + {config.holidayDates.length > 0 && ( +

+ {t("booking.holidaysTitle")}: {config.holidayDates.join("، ")} +

+ )} +

+ {t("booking.depositTitle")}:{" "} + {config.depositRequired ? t("common.yes") : t("common.no")} +

+
+
+ )} +
+ + + {t("booking.submitConfig")} + + +
+
+
+ ); +} + +export default BookingPreviewPage; diff --git a/src/app/settings/booking/new/schedule/page.tsx b/src/app/settings/booking/new/schedule/page.tsx new file mode 100644 index 0000000..0fd4347 --- /dev/null +++ b/src/app/settings/booking/new/schedule/page.tsx @@ -0,0 +1,279 @@ +"use client"; + +import AuthPageLayout, { + AuthFormFooter, + AuthPageContent, +} from "@/components/auth/AuthPageLayout"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import TimeSelect from "@/components/shops/TimeSelect"; +import ToggleSwitch from "@/components/shops/ToggleSwitch"; +import useAxios from "@/hooks/useAxios"; +import { useBookingWizardId } from "@/hooks/useBookingWizardId"; +import { useRouter } from "next/navigation"; +import { useEffect, 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]; + +type TimeRange = { start_time: string; end_time: string }; + +type WeeklyScheduleEntry = { day: Day; ranges: TimeRange[] }; + +function BookingSchedulePage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const configId = useBookingWizardId(); + + const [enabledDays, setEnabledDays] = useState>( + () => Object.fromEntries(DAYS.map((d) => [d, false])) as Record + ); + const [ranges, setRanges] = useState>( + () => + Object.fromEntries( + DAYS.map((d): [Day, TimeRange[]] => [d, []]) + ) as Record + ); + const [holidayDates, setHolidayDates] = useState([]); + const [newHoliday, setNewHoliday] = useState(""); + const [depositRequired, setDepositRequired] = useState(false); + + useEffect(() => { + if (!configId) return; + request<{ + config: { + weeklySchedule?: WeeklyScheduleEntry[]; + holidayDates?: string[]; + depositRequired?: boolean; + }; + }>("GET", `/bookings/${configId}`).then((res) => { + const config = res?.config; + if (!config) return; + if (config.weeklySchedule?.length) { + const nextEnabled = { ...enabledDays }; + const nextRanges = { ...ranges }; + config.weeklySchedule.forEach((entry) => { + nextEnabled[entry.day] = true; + nextRanges[entry.day] = entry.ranges || []; + }); + setEnabledDays(nextEnabled); + setRanges(nextRanges); + } + if (config.holidayDates?.length) { + setHolidayDates( + config.holidayDates.map((d) => new Date(d).toISOString().slice(0, 10)) + ); + } + if (config.depositRequired) setDepositRequired(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configId]); + + const toggleDay = (day: Day, checked: boolean) => { + setEnabledDays((prev) => ({ ...prev, [day]: checked })); + if (checked && ranges[day].length === 0) { + setRanges((prev) => ({ ...prev, [day]: [{ start_time: "", end_time: "" }] })); + } + }; + + const addRange = (day: Day) => { + setRanges((prev) => ({ + ...prev, + [day]: [...prev[day], { start_time: "", end_time: "" }], + })); + }; + + const removeRange = (day: Day, index: number) => { + setRanges((prev) => ({ + ...prev, + [day]: prev[day].filter((_, i) => i !== index), + })); + }; + + const updateRange = ( + day: Day, + index: number, + field: "start_time" | "end_time", + value: string + ) => { + setRanges((prev) => ({ + ...prev, + [day]: prev[day].map((r, i) => (i === index ? { ...r, [field]: value } : r)), + })); + }; + + const addHoliday = () => { + if (!newHoliday || holidayDates.includes(newHoliday)) return; + setHolidayDates((prev) => [...prev, newHoliday].sort()); + setNewHoliday(""); + }; + + const removeHoliday = (date: string) => { + setHolidayDates((prev) => prev.filter((d) => d !== date)); + }; + + const handleSubmit = async () => { + if (!configId) return; + + const weeklySchedule: WeeklyScheduleEntry[] = DAYS.filter( + (day) => enabledDays[day] && ranges[day].length > 0 + ).map((day) => ({ + day, + ranges: ranges[day].filter((r) => r.start_time && r.end_time), + })).filter((entry) => entry.ranges.length > 0); + + if (weeklySchedule.length === 0) { + toast.error(t("booking.scheduleRequired")); + return; + } + + try { + await request("PATCH", `/bookings/${configId}/schedule`, { + weeklySchedule, + holidayDates, + depositRequired, + }); + router.push("/settings/booking/new/preview"); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }; + + return ( + + +
+ + + {t("booking.scheduleTitle")} + + +
+ {DAYS.map((day) => ( +
+
+ {t(`shops.days.${day}`)} + toggleDay(day, checked)} + ariaLabel={t(`shops.days.${day}`)} + /> +
+ + {enabledDays[day] && ( +
+ {ranges[day].map((range, index) => ( +
+ updateRange(day, index, "start_time", v)} + ariaLabel={t("booking.startTime")} + /> + {t("booking.until")} + updateRange(day, index, "end_time", v)} + ariaLabel={t("booking.endTime")} + /> + +
+ ))} + +
+ )} +
+ ))} +
+ +
+ + {t("booking.holidaysTitle")} + +
+ setNewHoliday(e.target.value)} + className="flex-1 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50" + /> + +
+ {holidayDates.length > 0 && ( +
+ {holidayDates.map((date) => ( + + {date} + + + ))} +
+ )} +
+ +
+ {t("booking.depositTitle")} + +
+ {depositRequired && ( +

+ {t("booking.depositHint")} +

+ )} +
+ + + {t("shops.saveAndContinue")} + + +
+
+
+ ); +} + +export default BookingSchedulePage; diff --git a/src/app/settings/booking/new/services/page.tsx b/src/app/settings/booking/new/services/page.tsx new file mode 100644 index 0000000..afbd710 --- /dev/null +++ b/src/app/settings/booking/new/services/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import AuthPageLayout, { + AuthFormFooter, + AuthPageContent, +} from "@/components/auth/AuthPageLayout"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import ServiceItem from "@/components/main/Services/ServiceItem"; +import useAxios from "@/hooks/useAxios"; +import { useBookingWizardId } from "@/hooks/useBookingWizardId"; +import { Service, User } from "@/types/types"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +function BookingServicesPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request, loading } = useAxios(); + const configId = useBookingWizardId(); + + const [services, setServices] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + useEffect(() => { + request<{ user: User }>("GET", "/profile?services=1").then((res) => { + setServices(res?.user?.services || []); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!configId) return; + request<{ config: { services?: { serviceId: string }[] } }>( + "GET", + `/bookings/${configId}` + ).then((res) => { + const ids = (res?.config?.services || []).map((s) => s.serviceId); + if (ids.length) setSelectedIds(new Set(ids)); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configId]); + + const toggle = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleSubmit = async () => { + if (!configId || selectedIds.size === 0) return; + try { + await request("PATCH", `/bookings/${configId}/services`, { + serviceIds: Array.from(selectedIds), + }); + router.push("/settings/booking/new/schedule"); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message || t("shops.unknownError"); + toast.error(message); + } + }; + + return ( + + +
+ + + {t("booking.servicesTitle")} + + + {services === null ? ( +

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

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

{t("booking.noServicesYet")}

+ + {t("booking.goAddServices")} + +
+ ) : ( +
+ {services.map((service) => { + const id = service.id || service._id || ""; + const selected = selectedIds.has(id); + return ( + + ); + })} +
+ )} +
+ + + {t("shops.saveAndContinue")} + + +
+
+
+ ); +} + +export default BookingServicesPage; diff --git a/src/app/settings/booking/page.tsx b/src/app/settings/booking/page.tsx new file mode 100644 index 0000000..d798d8c --- /dev/null +++ b/src/app/settings/booking/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import PageTitle from "@/components/settings/PageTitle"; +import UserDetails from "@/components/settings/UserDetails"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { staticIconUrl } from "@/components/main/BaseUrl"; +import useAxios from "@/hooks/useAxios"; +import { saveBookingWizardId } from "@/hooks/useBookingWizardId"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; + +type BookingConfigSummary = { + _id: string; + name: string; + services: { title: string }[]; + status: "draft" | "active" | "inactive"; +}; + +function BookingSettingsPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request } = useAxios(); + const [configs, setConfigs] = useState(null); + + const load = () => { + request<{ configs: BookingConfigSummary[] }>("GET", "/bookings/mine").then( + (res) => setConfigs(res?.configs || []) + ); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openConfig = (config: BookingConfigSummary) => { + saveBookingWizardId(config._id); + router.push("/settings/booking/new/services?edit=1"); + }; + + const toggleStatus = async ( + e: React.MouseEvent, + config: BookingConfigSummary + ) => { + e.stopPropagation(); + const nextStatus = config.status === "active" ? "inactive" : "active"; + try { + await request("PATCH", `/bookings/${config._id}/status`, { + status: nextStatus, + }); + setConfigs( + (prev) => + prev?.map((c) => + c._id === config._id ? { ...c, status: nextStatus } : c + ) || null + ); + } catch { + toast.error(t("shops.unknownError")); + } + }; + + const statusColor = (status: string) => + status === "active" + ? "#008D0E" + : status === "inactive" + ? "#BA4141" + : "#BFAF19"; + + const statusLabel = (status: string) => + status === "active" + ? t("booking.statusActive") + : status === "inactive" + ? t("booking.statusInactive") + : t("booking.statusDraft"); + + return ( + + +
+ {t("settings.nav.booking")} + + + +
+
+ + +
+ {configs === null ? ( +

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

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

+ {t("booking.emptyList")} +

+ ) : ( + configs.map((config) => ( + + + )) + )} +
+
+
+
+ ); +} + +export default BookingSettingsPage; diff --git a/src/app/settings/booking/reservations/page.tsx b/src/app/settings/booking/reservations/page.tsx new file mode 100644 index 0000000..f8d2ada --- /dev/null +++ b/src/app/settings/booking/reservations/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import Container from "@/components/elements/Container"; +import PageTitle from "@/components/settings/PageTitle"; +import UserDetails from "@/components/settings/UserDetails"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import UserInfo from "@/components/main/UserInfo"; +import useAxios from "@/hooks/useAxios"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type BookingBuyer = { + _id: string; + first_name?: string; + last_name?: string; + user_name?: string; + profile_image?: string; + user_level?: string; + is_verified?: string; +}; + +type ReceivedBooking = { + _id: string; + buyer: BookingBuyer; + serviceTitle: string; + servicePrice: number; + date: string; + startTime: string; + endTime: string; + status: "pending_payment" | "confirmed" | "cancelled"; + createdAt: string; +}; + +function BookingReservationsPage() { + const { t } = useTranslation("common"); + const { request } = useAxios(); + const [bookings, setBookings] = useState(null); + + useEffect(() => { + request<{ bookings: ReceivedBooking[] }>( + "GET", + "/bookings/reservations?role=provider" + ).then((res) => setBookings(res?.bookings || [])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const statusColor = (status: ReceivedBooking["status"]) => + status === "confirmed" + ? "#008D0E" + : status === "cancelled" + ? "#BA4141" + : "#BFAF19"; + + const statusLabel = (status: ReceivedBooking["status"]) => + status === "confirmed" + ? t("booking.statusConfirmed") + : status === "cancelled" + ? t("booking.statusCancelled") + : t("booking.statusPendingPayment"); + + return ( + + + {t("booking.bookingsListTitle")} +
+ + +
+ {bookings === null ? ( +

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

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

+ {t("booking.bookingsListEmpty")} +

+ ) : ( + bookings.map((booking) => ( +
+
+ +
+ {statusLabel(booking.status)} +
+
+
+
+ {booking.serviceTitle} + + {Number(booking.servicePrice).toLocaleString()}{" "} + {t("settings.toman")} + +
+
+ {new Date(booking.date).toISOString().slice(0, 10)} + + {booking.startTime} - {booking.endTime} + +
+
+
+ )) + )} +
+
+
+
+ ); +} + +export default BookingReservationsPage; diff --git a/src/app/settings/wallet/page.tsx b/src/app/settings/wallet/page.tsx index 4792a5f..b4ca0b7 100644 --- a/src/app/settings/wallet/page.tsx +++ b/src/app/settings/wallet/page.tsx @@ -18,6 +18,7 @@ const BUCKET_GIFT = "gift"; const BUCKET_CHARGED = "charged"; const BUCKET_ACADEMY = "academy"; const BUCKET_PROJECT = "project"; +const BUCKET_BOOKING = "booking"; type WalletSummary = { shop: { available: number; pending: number }; @@ -25,6 +26,7 @@ type WalletSummary = { charged: { available: number }; academy: { available: number }; project: { available: number }; + booking: { available: number }; }; export default function WalletSettingsPage() { @@ -57,7 +59,8 @@ export default function WalletSettingsPage() { (summary?.gift.available || 0) + (summary?.charged.available || 0) + (summary?.academy.available || 0) + - (summary?.project.available || 0); + (summary?.project.available || 0) + + (summary?.booking.available || 0); const handleWithdraw = async () => { const amount = Number(withdrawAmount); @@ -138,6 +141,12 @@ export default function WalletSettingsPage() { > {t("shops.walletProjectTab")} + setBucket(BUCKET_BOOKING)} + > + {t("shops.walletBookingTab")} +
@@ -260,6 +269,33 @@ export default function WalletSettingsPage() { )} + {bucket === BUCKET_BOOKING && summary && ( + <> +
+ {t("shops.bookingBalance")} + + {summary.booking.available.toLocaleString()} {t("settings.toman")} + +
+
+ setWithdrawAmount(e.target.value)} + /> + + {t("shops.requestWithdrawal")} + +
+ + )} + {bucket === BUCKET_CHARGED && summary && ( <>
diff --git a/src/app/shops/listing/[listingId]/page.tsx b/src/app/shops/listing/[listingId]/page.tsx index 9f79e01..afd0e0e 100644 --- a/src/app/shops/listing/[listingId]/page.tsx +++ b/src/app/shops/listing/[listingId]/page.tsx @@ -4,6 +4,8 @@ import { generatePageMetadata } from "@/utils/generatePageMetadata"; import { getServerLanguage } from "@/lib/i18n/server"; import { getSiteSeoMeta } from "@/lib/i18n/seo"; import { buildStorageUrl } from "@/components/main/BaseUrl"; +import { buildProductJsonLd } from "@/lib/buildShopSeo"; +import { SITE_URL } from "@/config/pageSeo"; import { IShopProductListing } from "@/types/types"; import ListingDetailClient from "./ListingDetailClient"; @@ -81,6 +83,31 @@ export async function generateMetadata({ params }: IListingPageProps): Promise; +export default async function ListingDetailPage({ params }: IListingPageProps) { + const { listingId } = await params; + const lang = await getServerLanguage(); + const listing = await loadListing(listingId); + const shop = + listing?.shop && typeof listing.shop === "object" ? listing.shop : null; + + const jsonLd = listing + ? buildProductJsonLd( + listing, + shop, + `${SITE_URL}/shops/listing/${listingId}`, + lang + ) + : null; + + return ( + <> + {jsonLd && ( +