rezerv
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
280
src/app/booking/[userId]/page.tsx
Normal file
280
src/app/booking/[userId]/page.tsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
119
src/app/settings/booking/new/name/page.tsx
Normal file
119
src/app/settings/booking/new/name/page.tsx
Normal 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;
|
||||
136
src/app/settings/booking/new/preview/page.tsx
Normal file
136
src/app/settings/booking/new/preview/page.tsx
Normal 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;
|
||||
279
src/app/settings/booking/new/schedule/page.tsx
Normal file
279
src/app/settings/booking/new/schedule/page.tsx
Normal 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;
|
||||
142
src/app/settings/booking/new/services/page.tsx
Normal file
142
src/app/settings/booking/new/services/page.tsx
Normal 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;
|
||||
147
src/app/settings/booking/page.tsx
Normal file
147
src/app/settings/booking/page.tsx
Normal 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;
|
||||
124
src/app/settings/booking/reservations/page.tsx
Normal file
124
src/app/settings/booking/reservations/page.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
41
src/hooks/useBookingWizardId.ts
Normal file
41
src/hooks/useBookingWizardId.ts
Normal 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;
|
||||
}
|
||||
@@ -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
152
src/lib/buildShopSeo.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "مبلغ (تومان)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user