+ {listing.isTaggedPost && (
+
+ {t("shops.taggedPostBadge")}
+
+ )}
{typeof listing.shop === "object" && listing.shop?.name && (
{listing.shop.name}
diff --git a/src/hooks/useBookingWizardId.ts b/src/hooks/useBookingWizardId.ts
new file mode 100644
index 0000000..791c9b0
--- /dev/null
+++ b/src/hooks/useBookingWizardId.ts
@@ -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(null);
+
+ useEffect(() => {
+ const stored = getBookingWizardId();
+
+ if (!stored) {
+ router.replace("/settings/booking/new/name");
+ return;
+ }
+
+ setConfigId(stored);
+ }, [router]);
+
+ return configId;
+}
diff --git a/src/lib/api/upstreamProxy.ts b/src/lib/api/upstreamProxy.ts
index 2cb3d60..5a5e8ef 100644
--- a/src/lib/api/upstreamProxy.ts
+++ b/src/lib/api/upstreamProxy.ts
@@ -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;
diff --git a/src/lib/buildShopSeo.ts b/src/lib/buildShopSeo.ts
new file mode 100644
index 0000000..a3ebf8b
--- /dev/null
+++ b/src/lib/buildShopSeo.ts
@@ -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 },
+ ],
+ },
+ };
+}
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index f8491d2..a224353 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -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)",
diff --git a/src/locales/fa/common.json b/src/locales/fa/common.json
index a11c342..8d13770 100644
--- a/src/locales/fa/common.json
+++ b/src/locales/fa/common.json
@@ -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": "مبلغ (تومان)",
diff --git a/src/types/types.ts b/src/types/types.ts
index 9ad528d..404d329 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -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;