Initial commit: Hoomat storefront with Meshkee API and static-image slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
MESHKEE_API_BASE,
|
||||
MESHKEE_DOMAIN,
|
||||
} from "@/lib/meshkee/config";
|
||||
import type {
|
||||
AuthUser,
|
||||
BlogPost,
|
||||
Category,
|
||||
Comment,
|
||||
FaviconInfo,
|
||||
Paginated,
|
||||
Product,
|
||||
StaticImageSlot,
|
||||
StoreItemVariant,
|
||||
TechnicalInfo,
|
||||
Tenant,
|
||||
} from "@/lib/meshkee/types";
|
||||
|
||||
export class MeshkeeApiError extends Error {
|
||||
status: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(message: string, status: number, payload?: unknown) {
|
||||
super(message);
|
||||
this.name = "MeshkeeApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
type FetchOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
token?: string | null;
|
||||
signal?: AbortSignal;
|
||||
cache?: RequestCache;
|
||||
next?: NextFetchRequestConfig;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (options.token) {
|
||||
headers.Authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
};
|
||||
|
||||
// `next` / `cache` are server-only fetch extensions
|
||||
if (typeof window === "undefined") {
|
||||
if (options.cache) init.cache = options.cache;
|
||||
if (options.next) {
|
||||
(init as RequestInit & { next?: NextFetchRequestConfig }).next = options.next;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${MESHKEE_API_BASE}${path}`, init);
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const record =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const errorValue = record?.error;
|
||||
const messageValue = record?.message;
|
||||
const message =
|
||||
(typeof errorValue === "string" && errorValue) ||
|
||||
(typeof messageValue === "string" && messageValue) ||
|
||||
"خطا در ارتباط با سرور";
|
||||
throw new MeshkeeApiError(message, response.status, payload);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function tenantPath(suffix = "") {
|
||||
return `/tenants/${encodeURIComponent(MESHKEE_DOMAIN)}${suffix}`;
|
||||
}
|
||||
|
||||
export async function getTenant(): Promise<Tenant> {
|
||||
return request<Tenant>(tenantPath(), { next: { revalidate: 300 } });
|
||||
}
|
||||
|
||||
export async function getFavicon(): Promise<FaviconInfo> {
|
||||
return request<FaviconInfo>(tenantPath("/website/favicon"), {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStaticImages(
|
||||
pageKey?: string,
|
||||
): Promise<StaticImageSlot[]> {
|
||||
const query = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : "";
|
||||
const data = await request<{ items: StaticImageSlot[] }>(
|
||||
`${tenantPath("/website/static-images")}${query}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getStaticImageSlot(
|
||||
key: string,
|
||||
): Promise<StaticImageSlot | null> {
|
||||
try {
|
||||
return await request<StaticImageSlot>(
|
||||
tenantPath(`/website/static-images/${encodeURIComponent(key)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories(
|
||||
entityType: "product" | "blog" = "product",
|
||||
): Promise<Category[]> {
|
||||
const data = await request<{ items: Category[] }>(
|
||||
`${tenantPath("/categories")}?entityType=${entityType}`,
|
||||
{ next: { revalidate: 120 } },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function listStoreItems(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
name?: string;
|
||||
productId?: string;
|
||||
} = {}): Promise<Paginated<StoreItemVariant>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
if (params.pageSize) query.set("pageSize", String(params.pageSize));
|
||||
if (params.categoryId) query.set("categoryId", params.categoryId);
|
||||
if (params.name) query.set("name", params.name);
|
||||
if (params.productId) query.set("productId", params.productId);
|
||||
const qs = query.toString();
|
||||
return request<Paginated<StoreItemVariant>>(
|
||||
`${tenantPath("/store-items")}${qs ? `?${qs}` : ""}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStoreItemVariant(
|
||||
variantId: string,
|
||||
): Promise<StoreItemVariant> {
|
||||
const data = await request<{ variant: StoreItemVariant }>(
|
||||
tenantPath(`/store-items/${encodeURIComponent(variantId)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.variant;
|
||||
}
|
||||
|
||||
export async function getProductById(productId: string): Promise<{
|
||||
product: Product;
|
||||
relatedProducts: Product[];
|
||||
}> {
|
||||
const data = await request<{
|
||||
product: Product;
|
||||
relatedProducts?: Product[] | null;
|
||||
}>(tenantPath(`/products/by-id/${encodeURIComponent(productId)}`), {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
return {
|
||||
product: data.product,
|
||||
relatedProducts: data.relatedProducts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProductTechnicalInfo(
|
||||
productId: string,
|
||||
): Promise<TechnicalInfo> {
|
||||
return request<TechnicalInfo>(
|
||||
tenantPath(`/products/by-id/${encodeURIComponent(productId)}/technical-info`),
|
||||
{ next: { revalidate: 120 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listBlogs(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<Paginated<BlogPost>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
if (params.pageSize) query.set("pageSize", String(params.pageSize));
|
||||
const qs = query.toString();
|
||||
return request<Paginated<BlogPost>>(
|
||||
`${tenantPath("/blogs")}${qs ? `?${qs}` : ""}`,
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBlogById(blogId: string): Promise<BlogPost> {
|
||||
const data = await request<{ blog?: BlogPost } & BlogPost>(
|
||||
tenantPath(`/blogs/by-id/${encodeURIComponent(blogId)}`),
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
return data.blog ?? data;
|
||||
}
|
||||
|
||||
export async function listComments(params: {
|
||||
entityType: "product" | "blog";
|
||||
entityId: string;
|
||||
}): Promise<Comment[]> {
|
||||
const query = new URLSearchParams({
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
});
|
||||
const data = await request<{ items: Comment[] }>(
|
||||
`${tenantPath("/comments")}?${query.toString()}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function submitComment(
|
||||
body: {
|
||||
entityType: "product" | "blog";
|
||||
entityId: string;
|
||||
authorName: string;
|
||||
authorEmail?: string;
|
||||
text: string;
|
||||
},
|
||||
token?: string | null,
|
||||
) {
|
||||
return request(tenantPath("/comments"), {
|
||||
method: "POST",
|
||||
body,
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitContact(body: {
|
||||
title: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
text: string;
|
||||
}) {
|
||||
return request(tenantPath("/contact-submissions"), {
|
||||
method: "POST",
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMe(token: string, signal?: AbortSignal): Promise<AuthUser> {
|
||||
const data = await request<{ user?: AuthUser } & AuthUser>("/auth/me", {
|
||||
token,
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
return data.user ?? data;
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
return request<{ accessToken: string; refreshToken: string }>("/auth/refresh", {
|
||||
method: "POST",
|
||||
body: { refreshToken },
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAuthHandoff(token: string) {
|
||||
return request<{ ticket?: string }>("/auth/handoff", {
|
||||
method: "POST",
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCart(businessId: string, token: string) {
|
||||
return request(`/businesses/${encodeURIComponent(businessId)}/cart`, {
|
||||
token,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
body: { storeItemVariantId: string; quantity: number },
|
||||
) {
|
||||
return request(`/businesses/${encodeURIComponent(businessId)}/cart/items`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
itemId: string,
|
||||
quantity: number,
|
||||
) {
|
||||
return request(
|
||||
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: { quantity },
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeCartItem(
|
||||
businessId: string,
|
||||
token: string,
|
||||
itemId: string,
|
||||
) {
|
||||
return request(
|
||||
`/businesses/${encodeURIComponent(businessId)}/cart/items/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
token,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { CartLine, CartState } from "@/lib/meshkee/types";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && !Number.isNaN(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function emptyCart(): CartState {
|
||||
return { items: [], subtotal: 0, total: 0 };
|
||||
}
|
||||
|
||||
export function cartFromGuest(
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
price: number;
|
||||
image?: string | null;
|
||||
quantity: number;
|
||||
}>,
|
||||
): CartState {
|
||||
const lines: CartLine[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
storeItemVariantId: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
image: item.image ?? null,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
return { items: lines, subtotal, total: subtotal };
|
||||
}
|
||||
|
||||
export function normalizeCartResponse(payload: unknown): CartState {
|
||||
const root = asRecord(payload);
|
||||
const cart = asRecord(root.cart ?? root);
|
||||
const source = Array.isArray(cart.items)
|
||||
? cart.items
|
||||
: Array.isArray(root.items)
|
||||
? root.items
|
||||
: [];
|
||||
|
||||
const items: CartLine[] = source.map((raw) => {
|
||||
const item = asRecord(raw);
|
||||
const product = asRecord(item.product);
|
||||
const variant = asRecord(item.variant);
|
||||
const storeItem = asRecord(item.storeItem);
|
||||
const quantity = asNumber(item.quantity) ?? 1;
|
||||
const price =
|
||||
asNumber(item.unitPrice) ??
|
||||
asNumber(item.price) ??
|
||||
asNumber(item.discountedPrice) ??
|
||||
asNumber(variant.discountedPrice) ??
|
||||
asNumber(variant.price) ??
|
||||
asNumber(storeItem.discountedPrice) ??
|
||||
asNumber(storeItem.price) ??
|
||||
0;
|
||||
const selections = Array.isArray(item.selections)
|
||||
? item.selections
|
||||
: Array.isArray(variant.selections)
|
||||
? variant.selections
|
||||
: [];
|
||||
const optionLabel = selections
|
||||
.map((entry) => {
|
||||
const row = asRecord(entry);
|
||||
return asString(row.label || row.optionLabel || row.value);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return {
|
||||
id: asString(item.id || item.itemId),
|
||||
storeItemVariantId: asString(
|
||||
item.storeItemVariantId || variant.id || storeItem.id || item.variantId,
|
||||
),
|
||||
name: asString(
|
||||
item.productTitle ||
|
||||
item.productNameFa ||
|
||||
product.nameFa ||
|
||||
product.name ||
|
||||
product.title ||
|
||||
storeItem.productTitle ||
|
||||
storeItem.productNameFa ||
|
||||
"محصول",
|
||||
),
|
||||
slug: asString(product.slug || item.productSlug || storeItem.productSlug) || undefined,
|
||||
image:
|
||||
asString(
|
||||
item.productImage ||
|
||||
item.image ||
|
||||
product.image ||
|
||||
product.thumbnailUrl ||
|
||||
storeItem.productImage,
|
||||
) || null,
|
||||
price,
|
||||
quantity,
|
||||
optionLabel: optionLabel || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal =
|
||||
asNumber(cart.subtotal) ?? items.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
const total = asNumber(cart.total) ?? subtotal;
|
||||
return { items, subtotal, total };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const MESHKEE_DOMAIN = "hoomatstore.com";
|
||||
export const MESHKEE_API_BASE = "https://api.meshkee.com/api/v1";
|
||||
export const CUSTOMER_APP_URL = `https://customer.${MESHKEE_DOMAIN}`;
|
||||
export const BUSINESS_APP_URL = `https://business.${MESHKEE_DOMAIN}`;
|
||||
|
||||
export const ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
|
||||
export const REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
|
||||
export const GUEST_CART_KEY = "meshkee-guest-cart";
|
||||
export const GUEST_CART_URL_PARAM = "guestCart";
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Category, CategoryTreeNode, GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
export function slugifyFa(value: string | null | undefined): string {
|
||||
const raw = (value ?? "").trim().toLowerCase();
|
||||
if (!raw) return "item";
|
||||
return raw
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\u0600-\u06FFa-z0-9-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "item";
|
||||
}
|
||||
|
||||
export function formatPrice(value: number | null | undefined): string {
|
||||
const amount = Number(value ?? 0);
|
||||
return `${new Intl.NumberFormat("fa-IR").format(amount)} تومان`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayName(user: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}): string {
|
||||
const first = user.firstName?.trim();
|
||||
if (first) return first;
|
||||
const last = user.lastName?.trim();
|
||||
if (last) return last;
|
||||
return "کاربر";
|
||||
}
|
||||
|
||||
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
for (const category of categories) {
|
||||
if (category.isActive === false) continue;
|
||||
map.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const node of map.values()) {
|
||||
if (node.parentId && map.has(node.parentId)) {
|
||||
map.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
nodes.forEach((node) => sortNodes(node.children));
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function categoryLabel(category: Category): string {
|
||||
return category.nameFa?.trim() || category.name;
|
||||
}
|
||||
|
||||
export function storeItemName(item: {
|
||||
productNameFa?: string | null;
|
||||
productTitle?: string | null;
|
||||
label?: string | null;
|
||||
}): string {
|
||||
return item.productNameFa?.trim() || item.productTitle?.trim() || "محصول";
|
||||
}
|
||||
|
||||
export function storeItemPrice(item: {
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
}): number {
|
||||
return item.discountedPrice != null && item.discountedPrice > 0
|
||||
? item.discountedPrice
|
||||
: item.price;
|
||||
}
|
||||
|
||||
export function normalizeGuestLines(items: GuestCartLine[]): GuestCartLine[] {
|
||||
const result: GuestCartLine[] = [];
|
||||
for (const item of items) {
|
||||
const id = (item.id || "").trim();
|
||||
if (!id) continue;
|
||||
result.push({
|
||||
id,
|
||||
name: item.name || "محصول",
|
||||
slug: item.slug,
|
||||
price: Number(item.price) || 0,
|
||||
image: item.image ?? null,
|
||||
quantity: Math.max(1, Math.floor(item.quantity) || 1),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
GUEST_CART_KEY,
|
||||
GUEST_CART_URL_PARAM,
|
||||
} from "@/lib/meshkee/config";
|
||||
import { normalizeGuestLines } from "@/lib/meshkee/format";
|
||||
import type { GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
function parseLines(raw: string | null): GuestCartLine[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? normalizeGuestLines(parsed) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function readGuestCart(): GuestCartLine[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
return parseLines(localStorage.getItem(GUEST_CART_KEY));
|
||||
}
|
||||
|
||||
export function writeGuestCart(items: GuestCartLine[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
const normalized = normalizeGuestLines(items);
|
||||
localStorage.setItem(GUEST_CART_KEY, JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
export function clearGuestCart() {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(GUEST_CART_KEY);
|
||||
}
|
||||
|
||||
export function encodeGuestCartForUrl(items: GuestCartLine[]): string {
|
||||
const payload = normalizeGuestLines(items).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
...(item.slug ? { slug: item.slug } : {}),
|
||||
price: item.price,
|
||||
image: item.image ?? null,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(payload))))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export { GUEST_CART_URL_PARAM };
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { StaticImageItem, StaticImageSlot } from "@/lib/meshkee/types";
|
||||
|
||||
/**
|
||||
* Slot catalog for Meshkee dashboard import.
|
||||
* If UI image size/aspect changes during development, update these values.
|
||||
*/
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
{
|
||||
key: "slider",
|
||||
label: "Homepage slider",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "16:9",
|
||||
itemCount: null,
|
||||
recommendedWidth: 1440,
|
||||
},
|
||||
{
|
||||
key: "categories-icon",
|
||||
label: "Quick category icons",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: 6,
|
||||
recommendedWidth: 192,
|
||||
},
|
||||
{
|
||||
key: "special-sale-banner",
|
||||
label: "Special sale banner",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "3:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 520,
|
||||
},
|
||||
{
|
||||
key: "product-features-image",
|
||||
label: "Product features sofa image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "2:1",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 900,
|
||||
},
|
||||
{
|
||||
key: "contactus-image",
|
||||
label: "Homepage contact guarantee image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "4:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "two-banner-row",
|
||||
label: "Two banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: "aboutus-image",
|
||||
label: "Homepage about image",
|
||||
kind: "single" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "6:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "three-banner-row",
|
||||
label: "Three banner row",
|
||||
kind: "list" as const,
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: "about-us-image",
|
||||
label: "About page images",
|
||||
kind: "list" as const,
|
||||
pageKey: "about",
|
||||
aspectRatio: "6:5",
|
||||
itemCount: 2,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
];
|
||||
|
||||
export function findSlot(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageSlot | undefined {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export function slotImages(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageItem[] {
|
||||
const slot = findSlot(slots, key);
|
||||
if (!slot?.images?.length) return [];
|
||||
if (slot.kind === "single") {
|
||||
return slot.images[0]?.url ? [slot.images[0]] : [];
|
||||
}
|
||||
return slot.images.filter((image) => Boolean(image?.url));
|
||||
}
|
||||
|
||||
export function firstSlotImage(
|
||||
slots: StaticImageSlot[],
|
||||
key: string,
|
||||
): StaticImageItem | null {
|
||||
return slotImages(slots, key)[0] ?? null;
|
||||
}
|
||||
|
||||
export function imageTitleFa(image: StaticImageItem | null | undefined): string {
|
||||
return image?.titleFa?.trim() || image?.title?.trim() || "";
|
||||
}
|
||||
|
||||
export function imageTitleEn(image: StaticImageItem | null | undefined): string {
|
||||
return image?.titleEn?.trim() || "";
|
||||
}
|
||||
|
||||
export function imageSubtext(image: StaticImageItem | null | undefined): string {
|
||||
return image?.subtext?.trim() || "";
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
slug?: string;
|
||||
domain: string;
|
||||
logoUrl?: string | null;
|
||||
logoDarkUrl?: string | null;
|
||||
faviconUrl?: string | null;
|
||||
specialProductsSource?: "product" | "store_item";
|
||||
};
|
||||
|
||||
export type FaviconInfo = {
|
||||
faviconUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
hasDedicatedFavicon: boolean;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
slug: string;
|
||||
entityType?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type CategoryTreeNode = Category & { children: CategoryTreeNode[] };
|
||||
|
||||
export type StoreItemVariant = {
|
||||
id: string;
|
||||
storeItemId?: string;
|
||||
productId: string;
|
||||
productTitle?: string | null;
|
||||
productNameFa?: string | null;
|
||||
productImage?: string | null;
|
||||
productTotalStock?: number | null;
|
||||
selections?: Array<{
|
||||
variationId?: string;
|
||||
variationName?: string;
|
||||
optionId?: string;
|
||||
value?: string;
|
||||
}>;
|
||||
label?: string | null;
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
stockQuantity?: number | null;
|
||||
isFestival?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ProductImage = {
|
||||
mediaId?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
nameFa?: string | null;
|
||||
summary?: string | null;
|
||||
descriptionHtml?: string | null;
|
||||
slug?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string | null;
|
||||
images?: ProductImage[];
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
export type TechnicalInfo = {
|
||||
form: {
|
||||
fields: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
labelFa?: string;
|
||||
key?: string;
|
||||
type?: string;
|
||||
sortOrder?: number;
|
||||
options?: Array<{ id: string; label?: string; labelFa?: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
values: Array<{
|
||||
fieldId: string;
|
||||
textValue?: string | null;
|
||||
optionId?: string | null;
|
||||
optionIds?: string[] | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
type?: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
commentCount?: number;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
id: string;
|
||||
authorName?: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
cellNumber?: string | null;
|
||||
email?: string | null;
|
||||
roles?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
businesses?: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
isOwner?: boolean;
|
||||
teamRole?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CartLine = {
|
||||
id: string;
|
||||
storeItemVariantId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
image?: string | null;
|
||||
price: number;
|
||||
quantity: number;
|
||||
optionLabel?: string;
|
||||
};
|
||||
|
||||
export type CartState = {
|
||||
items: CartLine[];
|
||||
subtotal: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type GuestCartLine = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
price: number;
|
||||
image?: string | null;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type StaticImageItem = {
|
||||
id?: string;
|
||||
url: string;
|
||||
title?: string | null;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
subtext?: string | null;
|
||||
linkUrl?: string | null;
|
||||
};
|
||||
|
||||
export type StaticImageSlot = {
|
||||
key: string;
|
||||
pageKey?: string;
|
||||
kind: "single" | "list";
|
||||
label?: string;
|
||||
aspectRatio?: string;
|
||||
itemCount?: number | null;
|
||||
recommendedWidth?: number | null;
|
||||
images: StaticImageItem[];
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BUSINESS_APP_URL,
|
||||
CUSTOMER_APP_URL,
|
||||
} from "@/lib/meshkee/config";
|
||||
import {
|
||||
encodeGuestCartForUrl,
|
||||
GUEST_CART_URL_PARAM,
|
||||
} from "@/lib/meshkee/guest-cart";
|
||||
import { slugifyFa } from "@/lib/meshkee/format";
|
||||
import type { GuestCartLine } from "@/lib/meshkee/types";
|
||||
|
||||
export function customerLoginUrl(redirectPath = "/"): string {
|
||||
const url = new URL("/login", CUSTOMER_APP_URL);
|
||||
url.searchParams.set("redirect", redirectPath.startsWith("/") ? redirectPath : `/${redirectPath}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function customerProfileUrl(): string {
|
||||
return `${CUSTOMER_APP_URL}/`;
|
||||
}
|
||||
|
||||
export function businessDashboardUrl(): string {
|
||||
return `${BUSINESS_APP_URL}/`;
|
||||
}
|
||||
|
||||
export function customerContinueCheckoutUrl(
|
||||
isAuthenticated: boolean,
|
||||
guestItems: GuestCartLine[] = [],
|
||||
): string {
|
||||
if (isAuthenticated) {
|
||||
const url = new URL("/checkout/cart", CUSTOMER_APP_URL);
|
||||
if (guestItems.length > 0) {
|
||||
url.hash = `${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(guestItems)}`;
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
let redirect = "/checkout/cart";
|
||||
if (guestItems.length > 0) {
|
||||
redirect = `${redirect}#${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(guestItems)}`;
|
||||
}
|
||||
return customerLoginUrl(redirect);
|
||||
}
|
||||
|
||||
export function storeItemHref(variantId: string, name?: string | null): string {
|
||||
return `/store/${encodeURIComponent(variantId)}/${slugifyFa(name)}`;
|
||||
}
|
||||
|
||||
export function storeCategoryHref(categoryId: string, name?: string | null): string {
|
||||
return `/store?categoryId=${encodeURIComponent(categoryId)}&name=${encodeURIComponent(slugifyFa(name))}`;
|
||||
}
|
||||
|
||||
export function blogHref(id: string, title?: string | null): string {
|
||||
return `/blog/${encodeURIComponent(id)}/${slugifyFa(title)}`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
export const site = {
|
||||
name: "مبلمان هومت",
|
||||
title: "فروشگاه اینترنتی مبلمان و دکوراسیون | مبلمان هومت",
|
||||
description: "خرید آنلاین مبلمان راحتی، مدرن و کلاسیک با قیمتهای ویژه و کیفیت عالی",
|
||||
phone: "0992-3964060",
|
||||
mobile: "0902-0462518",
|
||||
whatsapp: "09223964060",
|
||||
palette: {
|
||||
primary: "#00234B",
|
||||
secondary: "#CC9966",
|
||||
tertiary: "#883F3F",
|
||||
cream: "#F8F3EC",
|
||||
ink: "#12151C",
|
||||
},
|
||||
logo: "/assets/hoomat-logo.png",
|
||||
heroImages: [
|
||||
"/assets/sample/spacejoy-c0JoR_-2x3E-unsplash.jpg",
|
||||
"/assets/sample/premium_photo-1682259448839-2ea3bc7f9e73.avif",
|
||||
"/assets/sample/charlesdeluvio-CdTH3UwWlR4-unsplash.jpg",
|
||||
],
|
||||
addresses: [
|
||||
"دفتر مرکزی: تهران _ شهرک غرب _ مجتمع تجاری پلاتین _ طبقه چهارم _ مبلمان هومت",
|
||||
"نماینده فروش: قم _ بازار مبل گنجوی _ بلوار نظامی گنجوی _ بین کوی 5 و 7 _ مبلمان هومت",
|
||||
"کارخانه: قم _ بلوار حضرت معصومه _ کوی 35 _ جنب قالیشویی امیرکبیر _ کارخانه مبلمان هومت",
|
||||
],
|
||||
};
|
||||
|
||||
export const navItems = [
|
||||
{ label: "محصولات", href: "/store" },
|
||||
{ label: "درباره ما", href: "/about-us" },
|
||||
{ label: "تماس با ما", href: "/contactus" },
|
||||
{ label: "مجله دکوراسیون", href: "/blog" },
|
||||
];
|
||||
|
||||
export const heroSlides = [
|
||||
{
|
||||
eyebrow: "ONLINE FURNITURE SHOP",
|
||||
title: "خرید اینترنتی مبلمان و دکوراسیون منزل",
|
||||
description:
|
||||
"تولید کننده انواع مبلمان راحتی و کلاسیک با امکان خرید آنلاین، مشاوره مستقیم و قیمتهای ویژه برای خانههای امروزی.",
|
||||
image: site.heroImages[0],
|
||||
},
|
||||
{
|
||||
eyebrow: "FURNITURE PRODUCER",
|
||||
title: "تولید کننده انواع مبلمان راحتی و کلاسیک",
|
||||
description:
|
||||
"مدلهای مینیمال، مدرن و کلاسیک را با راهنمایی مستقیم انتخاب کنید و متناسب با چیدمان خانه سفارش دهید.",
|
||||
image: site.heroImages[1],
|
||||
},
|
||||
{
|
||||
eyebrow: "SPECIAL SALE",
|
||||
title: "جشنواره مبلمان با قیمتهای ویژه",
|
||||
description:
|
||||
"برای انتخاب پارچه، رنگ و مدل دلخواه با مشاوران هومت در تماس باشید و خریدی مطمئنتر تجربه کنید.",
|
||||
image: site.heroImages[2],
|
||||
},
|
||||
];
|
||||
|
||||
export const categories = [
|
||||
{
|
||||
title: "مبل راحتی",
|
||||
subtitle: "Sofa Set",
|
||||
image: "/assets/sample/products/sam-moghadam-kvmdsTrGOBM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل کلاسیک",
|
||||
subtitle: "Royal Sofa Set",
|
||||
image: "/assets/sample/products/scopic-ltd-NLlWwR4d3qU-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "سرویس خواب",
|
||||
subtitle: "Bed Set",
|
||||
image: "/assets/sample/jean-philippe-delberghe-feijc-nqWKM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "میز ناهارخوری",
|
||||
subtitle: "Dinning Set Table",
|
||||
image: "/assets/sample/lucas-kepner-o_-snE5cDTI-unsplash.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export const categoryQuickLinks = [
|
||||
{ title: "مبل راحتی", icon: "sofa", iconImage: "/assets/sample/categories/sofa.png" },
|
||||
{ title: "مبل کلاسیک", icon: "chair", iconImage: "/assets/sample/categories/classic.png" },
|
||||
{ title: "مبل مینیمال", icon: "minimal", iconImage: "/assets/sample/categories/console.png" },
|
||||
{ title: "مبل تخت خواب شو", icon: "sleep", iconImage: "/assets/sample/categories/chair.png" },
|
||||
{ title: "میز ناهارخوری", icon: "table", iconImage: "/assets/sample/categories/dining-table.png" },
|
||||
{ title: "سرویس خواب", icon: "bed", iconImage: "/assets/sample/categories/bed.png" },
|
||||
];
|
||||
|
||||
export const features = [
|
||||
{
|
||||
title: "تولید کننده انواع مبلمان",
|
||||
subtitle: "FURNITURE PRODUCER",
|
||||
subtext: "خرید مستقیم از مجموعه هومت",
|
||||
},
|
||||
{
|
||||
title: "فروشگاه اینترنتی مبلمان",
|
||||
subtitle: "ONLINE FURNITURE SHOP",
|
||||
subtext: "انتخاب ساده و سفارش آنلاین",
|
||||
},
|
||||
{
|
||||
title: "ضمانت نامه کتبی مبلمان",
|
||||
subtitle: "GUARANTEE",
|
||||
subtext: "اطمینان بیشتر هنگام خرید",
|
||||
},
|
||||
{
|
||||
title: "ارسال مبل راحتی و کلاسیک",
|
||||
subtitle: "DELIVERY",
|
||||
subtext: "هماهنگی ارسال تا محل شما",
|
||||
},
|
||||
];
|
||||
|
||||
export const products = [
|
||||
{
|
||||
title: "مبل راحتی مینیمال 7 نفره دسته چوبی مدل سون",
|
||||
enName: "MINIMAL SOFA SEVEN",
|
||||
price: "85,900,000 تومان",
|
||||
badge: "جدیدترین مبل",
|
||||
image: "/assets/sample/products/ambo-ampeng-KEGGJPKxcSc-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مدرن 8 نفره مدل النا",
|
||||
enName: "ELENA SOFA FURNITURE",
|
||||
price: "209,250,000 تومان",
|
||||
badge: "Newest sofa",
|
||||
image: "/assets/sample/products/kevin-shek-kcxZ-YUZ8VE-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مینیمال مدل سارینا 8 نفره",
|
||||
enName: "SARINA SOFA FURNITURE",
|
||||
price: "تماس بگیرید",
|
||||
badge: "فروش ویژه",
|
||||
image: "/assets/sample/products/premium_photo-1682410454254-809412bf04bd.avif",
|
||||
},
|
||||
{
|
||||
title: "مبل راحتی مینیمال جدید مدل نوید 8 نفره",
|
||||
enName: "NAVID MINIMAL SOFA",
|
||||
price: "212,500,000 تومان",
|
||||
badge: "special sale",
|
||||
image: "/assets/sample/products/premium_photo-1683120739323-0a920bdb15a4.avif",
|
||||
},
|
||||
];
|
||||
|
||||
export const diningProducts = [
|
||||
{
|
||||
title: "ست میز ناهارخوری کلاسیک",
|
||||
enName: "CLASSIC DINING SET",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/products/sam-moghadam-xej3GOtAQ-o-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "ست میز ناهارخوری مدرن",
|
||||
enName: "MODERN DINING SET",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/premium_photo-1670950411934-e21abf39191e.avif",
|
||||
},
|
||||
{
|
||||
title: "صندلی ناهارخوری",
|
||||
enName: "DINING CHAIR",
|
||||
price: "تماس بگیرید",
|
||||
image: "/assets/sample/premium_photo-1682410455752-1007e8cea553.avif",
|
||||
},
|
||||
];
|
||||
|
||||
export const articles = [
|
||||
{
|
||||
title: "راهنمای خرید مبل راحتی با قیمت مناسب ارزشمند تر است تا تعمیر کردن مبل؟",
|
||||
date: "۱۲ مرداد ۱۴۰۴",
|
||||
image: "/assets/sample/spacejoy-c0JoR_-2x3E-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "خرید مبل اقساطی: خرید انواع مبلمان با شرایط ویژه",
|
||||
date: "۵ مرداد ۱۴۰۴",
|
||||
image: "/assets/sample/charlesdeluvio-CKbBn7ZhmyM-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "بازار مبل قم کجاست؟",
|
||||
date: "۲۸ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/charlesdeluvio-CdTH3UwWlR4-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "خرید مبل راحتی آنلاین | راهنمای جامع انتخاب بهترین مدلها",
|
||||
date: "۲۰ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/premium_photo-1682259448839-2ea3bc7f9e73.avif",
|
||||
},
|
||||
{
|
||||
title: "نکات کلیدی برای ست کردن مبلمان راحتی و میز ناهارخوری",
|
||||
date: "۱۴ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/lucas-kepner-o_-snE5cDTI-unsplash.jpg",
|
||||
},
|
||||
{
|
||||
title: "رنگ سال مبل ۱۴۰۴: ایدههایی برای دکوراسیون داخلی",
|
||||
date: "۸ تیر ۱۴۰۴",
|
||||
image: "/assets/sample/jean-philippe-delberghe-feijc-nqWKM-unsplash.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export const aboutCopy = {
|
||||
eyebrow: "About Us",
|
||||
title: "با مبلمان هومت بیشتر آشنا شویم",
|
||||
quote: "زیبایی را لمس کنید",
|
||||
storyTitle: "داستان مبلمان هومت",
|
||||
story:
|
||||
"مبلمان هومت فروشگاه آنلاین خرید مبلمان منزل با تنوع بالا و قیمت ویژه است. تمرکز ما تولید و عرضه انواع مبلمان راحتی، کلاسیک، مینیمال، سرویس خواب و میز ناهارخوری با کیفیت قابل اعتماد و تجربه خرید ساده است.",
|
||||
visionTitle: "دیدگاه ما",
|
||||
vision:
|
||||
"هدف هومت ساختن فضایی گرمتر برای خانههای ایرانی است؛ جایی که انتخاب مبلمان با اطلاعات کامل، قیمت شفاف، مشاوره مستقیم و پشتیبانی واقعی همراه باشد.",
|
||||
};
|
||||
Reference in New Issue
Block a user