This commit is contained in:
payacom
2026-08-05 15:12:39 +03:30
parent 89320d57b9
commit 491040f752
27 changed files with 1732 additions and 32 deletions

View File

@@ -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:

View File

@@ -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 [
{

View File

@@ -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

View File

@@ -637,7 +637,7 @@ useEffect(() => {
onKeyDown={preventDownload}
disablePictureInPicture
>
<source src={"https://app.modstagram.ir" + selectedVideo?.course_video} type="video/mp4" />
<source src={buildStorageUrl(selectedVideo?.course_video)} type="video/mp4" />
{t("academy.course.videoUnsupported")}
</video>

View File

@@ -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<BookingConfig | null | undefined>(undefined);
const [step, setStep] = useState<"service" | "datetime" | "confirm">("service");
const [selectedService, setSelectedService] = useState<BookingService | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [slots, setSlots] = useState<TimeSlot[] | null>(null);
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(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 (
<LocalePageShell>
<Container>
<PageTitle>{t("booking.bookOnline")}</PageTitle>
{config === undefined ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
) : !config ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("booking.emptyList")}
</p>
) : (
<div className="mt-6 text-sm">
{step === "service" && (
<div className="flex flex-col gap-2">
<p className="mb-2 font-bold">{t("booking.selectService")}</p>
{config.services.map((service) => (
<button
type="button"
key={service.serviceId}
onClick={() => {
setSelectedService(service);
setStep("datetime");
}}
className="flex items-center justify-between rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700"
>
<div className="flex items-center gap-2">
{service.image && (
<Image
src={buildStorageUrl(service.image)}
width={40}
height={40}
alt={service.title}
className="rounded-lg"
/>
)}
<span className="font-semibold">{service.title}</span>
</div>
<span className="text-neutral-500">
{Number(service.price).toLocaleString()} {t("settings.toman")}
</span>
</button>
))}
</div>
)}
{step === "datetime" && selectedService && (
<div className="flex flex-col gap-4">
<p className="font-bold">{t("booking.selectDateTime")}</p>
<div className="flex gap-2 overflow-x-auto pb-2">
{upcomingDates.map((date) => (
<button
type="button"
key={date}
onClick={() => setSelectedDate(date)}
className={`flex-shrink-0 rounded-xl border px-3 py-2 text-xs ${
selectedDate === date
? "border-[#0095f6] bg-[#0095f6]/10 font-bold"
: "border-neutral-200 dark:border-neutral-700"
}`}
>
{date}
</button>
))}
</div>
{selectedDate && (
<div>
{slots === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : slots.length === 0 ? (
<p className="text-neutral-500">
{t("booking.noAvailableSlots")}
</p>
) : (
<div className="flex flex-wrap gap-2">
{slots.map((slot) => (
<button
type="button"
key={`${slot.start_time}-${slot.end_time}`}
onClick={() => {
setSelectedSlot(slot);
setStep("confirm");
}}
className={`rounded-xl border px-3 py-2 text-xs ${
selectedSlot?.start_time === slot.start_time
? "border-[#0095f6] bg-[#0095f6]/10 font-bold"
: "border-neutral-200 dark:border-neutral-700"
}`}
>
{slot.start_time} - {slot.end_time}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
{step === "confirm" && selectedService && selectedDate && selectedSlot && (
<div className="flex flex-col gap-4">
<p className="font-bold">{t("booking.confirmBooking")}</p>
<div className="rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
<div className="flex justify-between py-1">
<span>{t("booking.selectService")}</span>
<span className="font-semibold">{selectedService.title}</span>
</div>
<div className="flex justify-between py-1">
<span>{t("booking.selectDateTime")}</span>
<span className="font-semibold">
{selectedDate} {selectedSlot.start_time}-{selectedSlot.end_time}
</span>
</div>
<div className="flex justify-between py-1">
<span>{t("settings.toman")}</span>
<span className="font-semibold">
{Number(selectedService.price).toLocaleString()}
</span>
</div>
{config.depositRequired && (
<div className="mt-2 flex justify-between border-t border-neutral-200 pt-2 dark:border-neutral-700">
<span>{t("booking.depositAmountLabel")}</span>
<span className="font-bold">
{Math.round(selectedService.price * 0.3).toLocaleString()}{" "}
{t("settings.toman")}
</span>
</div>
)}
</div>
<RoundedButton
variant="primary"
className="h-10"
disabled={loading}
onClick={handleConfirm}
>
{config.depositRequired
? t("booking.payDeposit")
: t("booking.confirmBooking")}
</RoundedButton>
</div>
)}
</div>
)}
</Container>
</LocalePageShell>
);
}
export default BookingFlowPage;

View File

@@ -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 {

View File

@@ -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 (
<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("booking.nameTitle")}
</span>
<RoundedInput
name="name"
type="text"
className={`max-w-[290px] text-center ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
: ""
}`}
placeholder={t("booking.namePlaceholder")}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{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 BookingNamePage;

View File

@@ -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<PreviewConfig | null>(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 (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center">
<AuthPageContent>
<span className="mb-4 text-xl font-bold">
{t("booking.previewTitle")}
</span>
{!config ? (
<p className="py-8 text-sm text-neutral-500">
{t("common.loading")}
</p>
) : (
<div className="flex w-full max-w-sm flex-col gap-4 text-sm">
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<p className="text-xs text-neutral-500">
{t("booking.nameTitle")}
</p>
<p className="font-bold">{config.name}</p>
</div>
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<p className="mb-2 text-xs text-neutral-500">
{t("booking.servicesTitle")}
</p>
{config.services.map((s) => (
<div key={s.title} className="flex justify-between py-1">
<span>{s.title}</span>
<span className="text-neutral-500">
{Number(s.price).toLocaleString()} {t("settings.toman")}
</span>
</div>
))}
</div>
<div className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<p className="mb-2 text-xs text-neutral-500">
{t("booking.scheduleTitle")}
</p>
{config.weeklySchedule.map((entry) => (
<div key={entry.day} className="py-1">
<span className="font-semibold">
{t(`shops.days.${entry.day}`)}:
</span>{" "}
{entry.ranges
.map((r) => `${r.start_time}-${r.end_time}`)
.join("، ")}
</div>
))}
{config.holidayDates.length > 0 && (
<p className="mt-2 text-xs text-neutral-500">
{t("booking.holidaysTitle")}: {config.holidayDates.join("، ")}
</p>
)}
<p className="mt-2 text-xs text-neutral-500">
{t("booking.depositTitle")}:{" "}
{config.depositRequired ? t("common.yes") : t("common.no")}
</p>
</div>
</div>
)}
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !config}
onClick={handleSubmit}
>
{t("booking.submitConfig")}
</AuthNextButton>
</AuthFormFooter>
</div>
</AuthPageLayout>
</LocalePageShell>
);
}
export default BookingPreviewPage;

View File

@@ -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<Record<Day, boolean>>(
() => Object.fromEntries(DAYS.map((d) => [d, false])) as Record<Day, boolean>
);
const [ranges, setRanges] = useState<Record<Day, TimeRange[]>>(
() =>
Object.fromEntries(
DAYS.map((d): [Day, TimeRange[]] => [d, []])
) as Record<Day, TimeRange[]>
);
const [holidayDates, setHolidayDates] = useState<string[]>([]);
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 (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center overflow-y-auto pb-6">
<AuthPageContent>
<span className="mb-4 text-xl font-bold">
{t("booking.scheduleTitle")}
</span>
<div className="flex w-full max-w-sm flex-col gap-3">
{DAYS.map((day) => (
<div
key={day}
className="rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{t(`shops.days.${day}`)}</span>
<ToggleSwitch
checked={enabledDays[day]}
onChange={(checked) => toggleDay(day, checked)}
ariaLabel={t(`shops.days.${day}`)}
/>
</div>
{enabledDays[day] && (
<div className="mt-3 flex flex-col gap-2">
{ranges[day].map((range, index) => (
<div key={index} className="flex items-center gap-2">
<TimeSelect
value={range.start_time}
onChange={(v) => updateRange(day, index, "start_time", v)}
ariaLabel={t("booking.startTime")}
/>
<span>{t("booking.until")}</span>
<TimeSelect
value={range.end_time}
onChange={(v) => updateRange(day, index, "end_time", v)}
ariaLabel={t("booking.endTime")}
/>
<button
type="button"
onClick={() => removeRange(day, index)}
className="mr-1 text-xs text-red-500"
>
{t("booking.removeRange")}
</button>
</div>
))}
<button
type="button"
onClick={() => addRange(day)}
className="self-start text-xs font-semibold text-[#0095f6]"
>
+ {t("booking.addRange")}
</button>
</div>
)}
</div>
))}
</div>
<div className="mt-6 w-full max-w-sm">
<span className="mb-2 block font-semibold">
{t("booking.holidaysTitle")}
</span>
<div className="flex items-center gap-2">
<input
type="date"
value={newHoliday}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addHoliday}
className="rounded-xl bg-neutral-100 px-3 py-2 text-sm font-semibold dark:bg-neutral-800"
>
{t("common.add")}
</button>
</div>
{holidayDates.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{holidayDates.map((date) => (
<span
key={date}
className="flex items-center gap-1 rounded-full bg-neutral-100 px-3 py-1 text-xs dark:bg-neutral-800"
>
{date}
<button
type="button"
onClick={() => removeHoliday(date)}
className="text-red-500"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div className="mt-6 flex w-full max-w-sm items-center justify-between rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
<span className="font-semibold">{t("booking.depositTitle")}</span>
<ToggleSwitch
checked={depositRequired}
onChange={setDepositRequired}
ariaLabel={t("booking.depositTitle")}
/>
</div>
{depositRequired && (
<p className="mt-2 max-w-sm text-xs text-neutral-500">
{t("booking.depositHint")}
</p>
)}
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !configId}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</div>
</AuthPageLayout>
</LocalePageShell>
);
}
export default BookingSchedulePage;

View File

@@ -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<Service[] | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center">
<AuthPageContent>
<span className="mb-4 text-xl font-bold">
{t("booking.servicesTitle")}
</span>
{services === null ? (
<p className="py-8 text-sm text-neutral-500">
{t("common.loading")}
</p>
) : services.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-8 text-center text-sm text-neutral-500">
<p>{t("booking.noServicesYet")}</p>
<Link
href="/settings/edit/services"
className="font-semibold text-[#0095f6]"
>
{t("booking.goAddServices")}
</Link>
</div>
) : (
<div className="flex w-full max-w-sm flex-col">
{services.map((service) => {
const id = service.id || service._id || "";
const selected = selectedIds.has(id);
return (
<button
type="button"
key={id}
onClick={() => toggle(id)}
className={`flex w-full items-center gap-2 rounded-2xl px-2 ${
selected ? "bg-[#0095f6]/10" : ""
}`}
>
<span
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 ${
selected
? "border-[#0095f6] bg-[#0095f6]"
: "border-neutral-300 dark:border-neutral-600"
}`}
>
{selected && (
<span className="h-2 w-2 rounded-full bg-white" />
)}
</span>
<ServiceItem service={service} />
</button>
);
})}
</div>
)}
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton
type="button"
loading={loading}
disabled={loading || !configId || selectedIds.size === 0}
onClick={handleSubmit}
>
{t("shops.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</div>
</AuthPageLayout>
</LocalePageShell>
);
}
export default BookingServicesPage;

View File

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

View File

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

View File

@@ -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")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(bucket === BUCKET_BOOKING), "h-9 text-xs")}
onClick={() => setBucket(BUCKET_BOOKING)}
>
{t("shops.walletBookingTab")}
</RoundedButton>
</div>
<div className="mx-auto mt-6 max-w-md text-sm">
@@ -260,6 +269,33 @@ export default function WalletSettingsPage() {
</>
)}
{bucket === BUCKET_BOOKING && summary && (
<>
<div className="flex justify-between">
<span>{t("shops.bookingBalance")}</span>
<span className="font-bold">
{summary.booking.available.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_CHARGED && summary && (
<>
<div className="flex justify-between">

View File

@@ -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<M
return { ...meta, title: { absolute: title } };
}
export default function ListingDetailPage() {
return <ListingDetailClient />;
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 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)}
<ListingDetailClient />
</>
);
}

View File

@@ -318,15 +318,21 @@ export default function ShopProfileClient() {
{listings.map((listing) => {
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
const tileId = listing.tileId || listing._id;
return (
<button
key={listing._id}
key={tileId}
type="button"
onClick={() =>
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
router.push(`/shops/profile/${shopId}/reel/${tileId}`)
}
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
>
{listing.isTaggedPost && (
<span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white">
{t("shops.taggedPostBadge")}
</span>
)}
{primaryImage && (
<Image
src={IMAGE_BASE_URL + primaryImage}

View File

@@ -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 { buildShopJsonLd } from "@/lib/buildShopSeo";
import { SITE_URL } from "@/config/pageSeo";
import ShopProfileClient from "./ShopProfileClient";
interface IShopProfilePageProps {
@@ -18,6 +20,7 @@ type PublicShop = {
province?: { name: string } | null;
city?: { name: string } | null;
address?: string | null;
rating?: { average: number; count: number } | null;
};
async function loadShop(shopId: string) {
@@ -71,6 +74,28 @@ export async function generateMetadata({ params }: IShopProfilePageProps): Promi
return { ...meta, title: { absolute: title } };
}
export default function ShopProfilePage() {
return <ShopProfileClient />;
export default async function ShopProfilePage({ params }: IShopProfilePageProps) {
const { shopId } = await params;
const lang = await getServerLanguage();
const shop = await loadShop(shopId);
const jsonLd = shop
? buildShopJsonLd(
{ ...shop, _id: shopId },
`${SITE_URL}/shops/profile/${shopId}`,
lang
)
: null;
return (
<>
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)}
<ShopProfileClient />
</>
);
}

View File

@@ -1150,7 +1150,7 @@ export function CourseForm({
<div className="mt-2">
<video
src={"https://app.modstagram.ir" + field.course_video}
src={buildStorageUrl(field.course_video)}
controls
className="w-full max-h-64 rounded-lg"
controlsList="nodownload"

View File

@@ -1,4 +1,4 @@
const REMOTE_API = "https://api.modstagram.ir";
const REMOTE_API = "https://api.modstagram.com";
/** آدرس API — در مرورگر همیشه relative (پروکسی Next) */
export function getApiBaseUrl(): string {

View File

@@ -173,6 +173,15 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
setShowModelDetailModal((prev) => !prev);
};
const handleCollaborationOrBooking = () => {
if (user?.has_active_booking) {
if (!requireAuth()) return;
router.push(`/booking/${user._id}`);
return;
}
openCollaboration();
};
const toggleFollow = useCallback(async () => {
if (!requireAuth() || !user?._id || isOwnProfile || loading) return;
@@ -355,7 +364,11 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<div
className={cn(
"mt-2 grid gap-1 md:mt-4 md:gap-4",
!user?.show_location ? "grid-cols-2" : "grid-cols-3"
[user?.show_location, user?.has_active_booking].filter(Boolean).length === 2
? "grid-cols-4"
: user?.show_location || user?.has_active_booking
? "grid-cols-3"
: "grid-cols-2"
)}
>
<Link href="/settings/profile/info" className={profileActionBtnClass}>
@@ -364,6 +377,14 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<Link href="/settings/chats" className={profileActionBtnClass}>
{t("models.actions.messages")}
</Link>
{user?.has_active_booking && (
<Link
href="/settings/booking/reservations"
className={profileActionBtnClass}
>
{t("booking.myBookings")}
</Link>
)}
<button
type="button"
onClick={openLocationModal}
@@ -385,10 +406,12 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
>
<button
type="button"
onClick={openCollaboration}
onClick={handleCollaborationOrBooking}
className={profileActionBtnClass}
>
{t("models.actions.collaboration")}
{user?.has_active_booking
? t("booking.bookOnline")
: t("models.actions.collaboration")}
</button>
<button
type="button"
@@ -420,10 +443,12 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<div className="mx-auto mt-2 grid max-w-xl grid-cols-3 justify-center gap-1 md:gap-2">
<button
type="button"
onClick={openCollaboration}
onClick={handleCollaborationOrBooking}
className={profileActionBtnClass}
>
{t("models.actions.inviteCollaboration")}
{user?.has_active_booking
? t("booking.bookOnline")
: t("models.actions.inviteCollaboration")}
</button>
<button
type="button"

View File

@@ -19,6 +19,7 @@ const SETTINGS_NAV = [
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
{ key: "workroom", href: "/workroom", icon: "box.svg" },
{ key: "booking", href: "/booking", icon: "calendar.svg" },
{ key: "offers", href: "/offers", icon: "offer.svg" },
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
{ key: "chats", href: "/chats", icon: "sms.svg" },

View File

@@ -109,7 +109,9 @@ export default function ShopReelsView({
const initialIndex = useMemo(() => {
if (!listings) return 0;
const idx = listings.findIndex((l) => l._id === initialListingId);
const idx = listings.findIndex(
(l) => (l.tileId || l._id) === initialListingId
);
return idx >= 0 ? idx : 0;
}, [listings, initialListingId]);
@@ -171,7 +173,7 @@ export default function ShopReelsView({
const favorited = favoriteIds.has(listing._id);
return (
<ReelsScrollSlot key={listing._id} index={index} activeIndex={activeIndex}>
<ReelsScrollSlot key={listing.tileId || listing._id} index={index} activeIndex={activeIndex}>
<div className="relative flex h-full w-full items-center justify-center bg-black">
{primaryImage && (
<Image
@@ -252,6 +254,11 @@ export default function ShopReelsView({
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pb-8 text-white">
{listing.isTaggedPost && (
<span className="mb-1 inline-block rounded-full bg-white/15 px-2 py-0.5 text-[10px] font-semibold text-neutral-200">
{t("shops.taggedPostBadge")}
</span>
)}
{typeof listing.shop === "object" && listing.shop?.name && (
<p className="mb-1 text-xs font-semibold text-neutral-300">
{listing.shop.name}

View File

@@ -0,0 +1,41 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const STORAGE_KEY = "booking_wizard_config_id";
export function saveBookingWizardId(configId: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, configId);
}
export function clearBookingWizardId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEY);
}
/** Synchronous, no-redirect read — used by the name step to detect edit mode. */
export function getBookingWizardId(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_KEY);
}
/** Reads the in-progress booking-config id saved by the name step; redirects back to it if missing. */
export function useBookingWizardId(): string | null {
const router = useRouter();
const [configId, setConfigId] = useState<string | null>(null);
useEffect(() => {
const stored = getBookingWizardId();
if (!stored) {
router.replace("/settings/booking/new/name");
return;
}
setConfigId(stored);
}, [router]);
return configId;
}

View File

@@ -1,5 +1,5 @@
export const UPSTREAM_API =
process.env.UPSTREAM_API_URL ?? "https://api.modstagram.ir";
process.env.UPSTREAM_API_URL ?? "https://api.modstagram.com";
type ProxyOptions = {
stripAuth?: boolean;

152
src/lib/buildShopSeo.ts Normal file
View File

@@ -0,0 +1,152 @@
import { SITE_URL } from "@/config/pageSeo";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { IShopProductListing } from "@/types/types";
type ShopInfo = {
_id: string;
name: string;
logo?: string | null;
description?: string | null;
address?: string | null;
province?: { name: string } | null;
city?: { name: string } | null;
has_physical_location?: boolean;
rating?: { average: number; count: number } | null;
};
function cheapestAndDearest(listing: IShopProductListing) {
const prices = (listing.variants || []).map((v) =>
v.discount_price != null ? v.discount_price : v.price
);
if (!prices.length) return { low: null, high: null };
return { low: Math.min(...prices), high: Math.max(...prices) };
}
function totalStock(listing: IShopProductListing): number {
return (listing.variants || []).reduce((sum, v) => sum + (v.stock || 0), 0);
}
/** Product + Offer/AggregateOffer JSON-LD for a shop product listing page. */
export function buildProductJsonLd(
listing: IShopProductListing,
shop: ShopInfo | null,
url: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const siteName = getSiteSeoMeta(lang).name;
const images = (listing.images || []).map((img) => buildStorageUrl(img)).filter(Boolean);
const { low, high } = cheapestAndDearest(listing);
const inStock = totalStock(listing) > 0;
const skus = Array.from(
new Set((listing.variants || []).map((v) => v.sku).filter(Boolean))
);
const offerBase = {
priceCurrency: "IRR",
availability: inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
url,
seller: shop
? { "@type": "Organization", name: shop.name }
: undefined,
};
const offers =
low != null && high != null
? low === high
? { "@type": "Offer", ...offerBase, price: low }
: {
"@type": "AggregateOffer",
...offerBase,
lowPrice: low,
highPrice: high,
offerCount: listing.variants?.length || 1,
}
: undefined;
return {
"@context": "https://schema.org",
"@type": "Product",
name: listing.title,
description: listing.description || undefined,
image: images.length ? images : undefined,
sku: skus.length === 1 ? skus[0] : undefined,
category: listing.category || undefined,
brand: shop ? { "@type": "Brand", name: shop.name } : undefined,
offers,
url,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: siteName, item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "فروشگاه‌ها", item: `${SITE_URL}/shops/discover` },
...(shop
? [
{
"@type": "ListItem",
position: 3,
name: shop.name,
item: `${SITE_URL}/shops/profile/${shop._id}`,
},
]
: []),
{
"@type": "ListItem",
position: shop ? 4 : 3,
name: listing.title,
item: url,
},
],
},
};
}
/** LocalBusiness/Store JSON-LD for a shop's public profile page. */
export function buildShopJsonLd(
shop: ShopInfo,
url: string,
lang: AppLanguage = DEFAULT_LANGUAGE
) {
const siteName = getSiteSeoMeta(lang).name;
const address =
shop.province?.name || shop.city?.name || shop.address
? {
"@type": "PostalAddress",
addressRegion: shop.province?.name || undefined,
addressLocality: shop.city?.name || undefined,
streetAddress: shop.address || undefined,
addressCountry: "IR",
}
: undefined;
const aggregateRating =
shop.rating && shop.rating.count > 0
? {
"@type": "AggregateRating",
ratingValue: shop.rating.average,
reviewCount: shop.rating.count,
}
: undefined;
return {
"@context": "https://schema.org",
"@type": shop.has_physical_location ? "LocalBusiness" : "OnlineStore",
name: shop.name,
description: shop.description || undefined,
image: shop.logo ? buildStorageUrl(shop.logo) : undefined,
url,
address,
aggregateRating,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: siteName, item: SITE_URL },
{ "@type": "ListItem", position: 2, name: "فروشگاه‌ها", item: `${SITE_URL}/shops/discover` },
{ "@type": "ListItem", position: 3, name: shop.name, item: url },
],
},
};
}

View File

@@ -17,7 +17,54 @@
"search": "Search…",
"clear": "Clear",
"error": "Error",
"close": "Close"
"close": "Close",
"yes": "Yes",
"no": "No",
"add": "Add"
},
"booking": {
"nameRequired": "Booking name is required",
"nameTitle": "Booking name",
"namePlaceholder": "e.g. Salon booking",
"servicesTitle": "Bookable services",
"noServicesYet": "You haven't added any services to your profile yet.",
"goAddServices": "Add services in profile edit",
"scheduleTitle": "Working hours",
"scheduleRequired": "Define at least one time range for one day",
"startTime": "Start time",
"until": "to",
"endTime": "End time",
"removeRange": "Remove",
"addRange": "Add range",
"holidaysTitle": "Holidays",
"depositTitle": "Require deposit",
"depositHint": "The buyer must pay 30% of the service price online to confirm the booking.",
"previewTitle": "Booking preview",
"submitConfig": "Publish booking",
"submitSuccess": "Booking published successfully",
"statusActive": "Active",
"statusInactive": "Inactive",
"statusDraft": "Draft",
"addNew": "Add booking",
"emptyList": "No bookings created yet.",
"servicesCount_one": "{{count}} service",
"servicesCount_other": "{{count}} services",
"bookOnline": "Book online",
"myBookings": "My bookings",
"bookingsListTitle": "Received bookings",
"bookingsListEmpty": "No bookings received yet.",
"selectService": "Select a service",
"selectDateTime": "Select date and time",
"noAvailableSlots": "No available slots for this day",
"confirmBooking": "Confirm booking",
"depositAmountLabel": "Deposit amount (30%)",
"payDeposit": "Pay deposit and confirm booking",
"bookingConfirmed": "Your booking was confirmed",
"notificationTitle": "New booking",
"notificationBody": "{{service}} was booked for {{date}} at {{time}}",
"statusPendingPayment": "Awaiting payment",
"statusConfirmed": "Confirmed",
"statusCancelled": "Cancelled"
},
"verificationBadge": {
"licenseAlt": "License badge",
@@ -75,6 +122,7 @@
"workroom": "My projects",
"billboards": "Billboards",
"academy": "Academy",
"booking": "Online Booking Settings",
"offers": "Requests",
"shop": "Shop",
"wallet": "Wallet",
@@ -2456,6 +2504,7 @@
"saveProduct": "Save product",
"productSaved": "Product saved successfully",
"noProductsYet": "No products yet",
"taggedPostBadge": "Post",
"priceLabel": "Price: {{amount}} Toman",
"addProduct": "Add product",
"comparisonTitle": "Compare shop prices",
@@ -2551,6 +2600,8 @@
"academyBalance": "Academy balance",
"walletProjectTab": "Project collaboration",
"projectBalance": "Project collaboration balance",
"walletBookingTab": "Online booking",
"bookingBalance": "Online booking balance",
"availableToWithdraw": "Available to withdraw",
"pendingConfirmation": "Registered orders amount",
"withdrawAmountPlaceholder": "Amount (Toman)",

View File

@@ -17,7 +17,54 @@
"search": "جستجو…",
"clear": "پاک",
"error": "خطا",
"close": "بستن"
"close": "بستن",
"yes": "بله",
"no": "خیر",
"add": "افزودن"
},
"booking": {
"nameRequired": "نام رزرو الزامی است",
"nameTitle": "نام رزرو",
"namePlaceholder": "مثلاً رزرو آرایشگاه",
"servicesTitle": "خدمات قابل رزرو",
"noServicesYet": "هنوز خدمتی در پروفایل شما ثبت نشده است.",
"goAddServices": "افزودن خدمات در ویرایش پروفایل",
"scheduleTitle": "ساعات کاری",
"scheduleRequired": "حداقل یک بازه زمانی برای یک روز تعریف کنید",
"startTime": "ساعت شروع",
"until": "تا",
"endTime": "ساعت پایان",
"removeRange": "حذف",
"addRange": "افزودن بازه",
"holidaysTitle": "روزهای تعطیل",
"depositTitle": "دریافت بیعانه",
"depositHint": "کاربر برای ثبت رزرو باید ۳۰٪ مبلغ خدمت را آنلاین پرداخت کند.",
"previewTitle": "پیش‌نمایش رزرو",
"submitConfig": "ثبت رزرو",
"submitSuccess": "رزرو با موفقیت ثبت شد",
"statusActive": "فعال",
"statusInactive": "غیرفعال",
"statusDraft": "پیش‌نویس",
"addNew": "افزودن رزرو",
"emptyList": "هنوز رزروی ثبت نشده است.",
"servicesCount_one": "{{count}} خدمت",
"servicesCount_other": "{{count}} خدمت",
"bookOnline": "رزرو آنلاین",
"myBookings": "رزرو های من",
"bookingsListTitle": "رزروهای دریافتی",
"bookingsListEmpty": "هنوز رزروی دریافت نکرده‌اید.",
"selectService": "انتخاب خدمت",
"selectDateTime": "انتخاب تاریخ و ساعت",
"noAvailableSlots": "برای این روز زمانی خالی نیست",
"confirmBooking": "تایید رزرو",
"depositAmountLabel": "مبلغ بیعانه (۳۰٪)",
"payDeposit": "پرداخت بیعانه و ثبت رزرو",
"bookingConfirmed": "رزرو شما با موفقیت ثبت شد",
"notificationTitle": "رزرو جدید",
"notificationBody": "خدمت {{service}} در تاریخ {{date}} ساعت {{time}} رزرو شد",
"statusPendingPayment": "در انتظار پرداخت",
"statusConfirmed": "تایید شده",
"statusCancelled": "لغو شده"
},
"verificationBadge": {
"licenseAlt": "تیک مجوز",
@@ -75,6 +122,7 @@
"workroom": "پروژه‌های من",
"billboards": "بیلبورد",
"academy": "آموزشگاه",
"booking": "تنظیمات رزرو آنلاین",
"offers": "درخواست ها",
"shop": "فروشگاه",
"wallet": "کیف پول",
@@ -2455,6 +2503,7 @@
"saveProduct": "ثبت کالا",
"productSaved": "کالا با موفقیت ثبت شد",
"noProductsYet": "هنوز کالایی ثبت نشده است",
"taggedPostBadge": "پست",
"priceLabel": "قیمت: {{amount}} تومان",
"addProduct": "افزودن کالا",
"comparisonTitle": "مقایسه قیمت فروشگاه‌ها",
@@ -2550,6 +2599,8 @@
"academyBalance": "موجودی آموزشگاه",
"walletProjectTab": "همکاری پروژه",
"projectBalance": "موجودی همکاری پروژه",
"walletBookingTab": "رزرو آنلاین",
"bookingBalance": "موجودی رزرو آنلاین",
"availableToWithdraw": "قابل برداشت",
"pendingConfirmation": "مبلغ سفارش‌های ثبت‌شده",
"withdrawAmountPlaceholder": "مبلغ (تومان)",

View File

@@ -128,6 +128,12 @@ export interface IShopProductListing {
createdAt?: string;
/** Number of active listings across all shops sharing this catalog product (discover feed only). */
shop_count?: number;
/** Unique key for this tile — the real listing's _id, or `post-{postId}` for a tagged-post tile. Use for React keys and reel navigation instead of _id. */
tileId?: string;
/** True when this tile's images come from a regular post that tagged this product, not the listing itself. */
isTaggedPost?: boolean;
postId?: string;
postCaption?: string | null;
}
export interface LastPost {
_id: string;
@@ -247,6 +253,7 @@ export interface User {
is_following?: boolean;
blocked_you?: boolean;
offer_status?: boolean | null;
has_active_booking?: boolean;
monthly_free_offer?: number;
sub_expertise?: string[];
display_sub_expertise?: string | null;