Build the Salar Store storefront and connect products, blogs, and home specials to the Meshkee API.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2783305f07
commit
7d26eaa838
+211
@@ -0,0 +1,211 @@
|
||||
import { API } from "./site";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
async function apiGet<T>(path: string, query?: Query): Promise<T> {
|
||||
const url = new URL(`${API.base}${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, `API ${response.status} ${path}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ApiProduct = {
|
||||
id: string;
|
||||
title: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
summary?: string | null;
|
||||
descriptionHtml?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string | null;
|
||||
thumbnail?: string | null;
|
||||
images?: { mediaId: string; url: string }[];
|
||||
commentCount?: number;
|
||||
store?: {
|
||||
variantCount: number;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
inStock: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiComment = {
|
||||
id: string;
|
||||
authorName?: string | null;
|
||||
text?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type ApiBlog = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
categoryName?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
author?: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type ApiVariant = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
stockQuantity?: number | null;
|
||||
sortOrder?: number;
|
||||
selections?: {
|
||||
variationId: string;
|
||||
variationName: string;
|
||||
optionId: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ApiSpecialItem = {
|
||||
id: string;
|
||||
productId: string;
|
||||
productTitle: string;
|
||||
productNameFa: string | null;
|
||||
productImage: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
variants: ApiVariant[];
|
||||
};
|
||||
|
||||
export type ApiStoreSpecial = {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
items: ApiSpecialItem[];
|
||||
};
|
||||
|
||||
export type ApiStoreItem = {
|
||||
id: string;
|
||||
productId: string;
|
||||
variants: ApiVariant[];
|
||||
};
|
||||
|
||||
const tenant = () => `/tenants/${API.domain}`;
|
||||
|
||||
export async function getProducts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
} = {}) {
|
||||
return apiGet<Paginated<ApiProduct>>(`${tenant()}/products`, query);
|
||||
}
|
||||
|
||||
export async function getProduct(slug: string) {
|
||||
try {
|
||||
const data = await apiGet<{ product: ApiProduct }>(`${tenant()}/products/${slug}`);
|
||||
return data.product;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
|
||||
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
|
||||
}
|
||||
|
||||
export async function getBlog(slug: string) {
|
||||
try {
|
||||
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/${slug}`);
|
||||
return data.blog;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoreSpecials() {
|
||||
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getStoreItemByProduct(productId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
|
||||
return data.storeItem;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
try {
|
||||
const data = await apiGet<{ items: ApiCategory[] }>(`${tenant()}/categories`, { entityType: "product" });
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategoryBySlug(slug: string) {
|
||||
const categories = await getCategories();
|
||||
return categories.find((category) => category.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function getComments(entityType: "product" | "blog", entityId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ items: ApiComment[] }>(`${tenant()}/comments`, { entityType, entityId });
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProductSlug(productId: string, name: string) {
|
||||
if (!name) return "";
|
||||
const { items } = await getProducts({ name, pageSize: 20 });
|
||||
return items.find((item) => item.id === productId)?.slug ?? "";
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
export type CartItem = {
|
||||
productId: string;
|
||||
variantId?: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
image: string;
|
||||
price: number;
|
||||
qty: number;
|
||||
};
|
||||
|
||||
const KEY = "salar-cart";
|
||||
|
||||
export function getCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as CartItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function cartCount() {
|
||||
return getCart().reduce((sum, item) => sum + item.qty, 0);
|
||||
}
|
||||
|
||||
export function addToCart(item: CartItem) {
|
||||
const cart = getCart();
|
||||
const index = cart.findIndex(
|
||||
(entry) => entry.productId === item.productId && entry.variantId === item.variantId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
cart[index] = { ...cart[index], qty: cart[index].qty + item.qty };
|
||||
} else {
|
||||
cart.push(item);
|
||||
}
|
||||
window.localStorage.setItem(KEY, JSON.stringify(cart));
|
||||
window.dispatchEvent(new Event("cart-updated"));
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import { samples } from "./samples";
|
||||
|
||||
export type ImageSrc = string | StaticImageData;
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: number;
|
||||
oldPrice?: number;
|
||||
stock?: number | null;
|
||||
image: ImageSrc;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
image: ImageSrc;
|
||||
abstract?: string;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
name: string;
|
||||
english: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
export type ParentCategory = {
|
||||
name: string;
|
||||
slug: string;
|
||||
children: { name: string; slug: string }[];
|
||||
};
|
||||
|
||||
export const heroSlides = [
|
||||
{
|
||||
image: samples.glenJackson,
|
||||
title: "بهترین لوازم کمپ",
|
||||
english: "GEAR FOR THE WILD",
|
||||
subtitle: "از سالار استور",
|
||||
button: "شروع به خرید",
|
||||
href: "/products",
|
||||
},
|
||||
{
|
||||
image: samples.henryXu,
|
||||
title: "همراه شما در کوهستان",
|
||||
english: "BUILT FOR THE SUMMIT",
|
||||
subtitle: "تجهیزات حرفهای کوهنوردی",
|
||||
button: "مشاهده محصولات",
|
||||
href: "/products/camping-and-mountaineering",
|
||||
},
|
||||
{
|
||||
image: samples.jesseGardner,
|
||||
title: "کمپینگ فراموشنشدنی",
|
||||
english: "NIGHTS WORTH THE CLIMB",
|
||||
subtitle: "چادر، کیسه خواب و بیشتر",
|
||||
button: "خرید چادر",
|
||||
href: "/products/sleeping-tent",
|
||||
},
|
||||
];
|
||||
|
||||
export const heroSides = [
|
||||
{
|
||||
image: samples.jiscaLucia,
|
||||
title: "پوشاک طبیعتگردی",
|
||||
english: "WEAR THE TRAIL",
|
||||
subtitle: "آماده برای هر مسیر",
|
||||
href: "/products/clothing",
|
||||
},
|
||||
{
|
||||
image: samples.fabioComparelli,
|
||||
title: "ماجراجویی ادامه دارد",
|
||||
english: "KEEP GOING",
|
||||
subtitle: "لوازم کمپ و کوه",
|
||||
href: "/products/camping-and-mountaineering",
|
||||
},
|
||||
];
|
||||
|
||||
export const PLACEHOLDER = {
|
||||
product: "/images/placeholders/product.png",
|
||||
blog: "/images/placeholders/blog.png",
|
||||
about: samples.jiscaLucia,
|
||||
specialSale: "/images/promo/special-sale.png",
|
||||
topSelling: "/images/promo/top-selling.png",
|
||||
slider: heroSlides.map((slide) => slide.image),
|
||||
sliderSides: heroSides.map((side) => side.image),
|
||||
banners4: [samples.phillipsJacobe, samples.paulGreen, samples.victorLarracuente, samples.seanBenesh],
|
||||
banners3: [samples.kevinSchmid, samples.bradenCollum, samples.jesseGardner],
|
||||
};
|
||||
|
||||
export const parentCategories: ParentCategory[] = [
|
||||
{
|
||||
name: "پوشاک",
|
||||
slug: "clothing",
|
||||
children: [
|
||||
{ name: "کاپشن", slug: "jacket" },
|
||||
{ name: "بادگیر", slug: "windbreaker" },
|
||||
{ name: "گتر", slug: "mountaineering-gaiter" },
|
||||
{ name: "دستمال سر و گردن", slug: "neck-scarf" },
|
||||
{ name: "تیشرت", slug: "t-shirt" },
|
||||
{ name: "بیس لایر", slug: "base-layer" },
|
||||
{ name: "پانچو", slug: "raincoat" },
|
||||
{ name: "شلوار", slug: "pants" },
|
||||
{ name: "کلاه", slug: "hat" },
|
||||
{ name: "دستکش", slug: "gloves" },
|
||||
{ name: "جوراب", slug: "socks" },
|
||||
{ name: "عینک", slug: "glasses" },
|
||||
{ name: "کیف کمری", slug: "waist-bag" },
|
||||
{ name: "سویشرت", slug: "sweatshirt" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "کفش",
|
||||
slug: "shoe",
|
||||
children: [
|
||||
{ name: "صندل", slug: "sandal" },
|
||||
{ name: "کفش کوهنوردی", slug: "shoe" },
|
||||
{ name: "یخ شکن", slug: "crampons" },
|
||||
{ name: "لوازم جانبی کفش", slug: "shoe-accessories" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "کمپینگ و کوهنوردی",
|
||||
slug: "camping-and-mountaineering",
|
||||
children: [
|
||||
{ name: "کیسه خواب", slug: "sleeping-bag" },
|
||||
{ name: "کوله پشتی", slug: "backpack" },
|
||||
{ name: "چادر خواب", slug: "sleeping-tent" },
|
||||
{ name: "قاشق چنگال", slug: "spoon-fork" },
|
||||
{ name: "ظروف", slug: "containers" },
|
||||
{ name: "باتوم کوهنوردی", slug: "mountaineering-baton" },
|
||||
{ name: "چاقو", slug: "knife" },
|
||||
{ name: "کپسول و اجاق گاز", slug: "travel-stove" },
|
||||
{ name: "فندک و چخماق", slug: "lighter" },
|
||||
{ name: "چراغ قوه و هدلایت", slug: "flashlight-and-headlights" },
|
||||
{ name: "زیرانداز", slug: "underlay" },
|
||||
{ name: "سایه بان", slug: "camping-canopy" },
|
||||
{ name: "فلاسک", slug: "flask" },
|
||||
{ name: "بیل و کلنگ", slug: "camping-shovel-and-pickaxe" },
|
||||
{ name: "صندلی", slug: "camping-chairs" },
|
||||
{ name: "تبر", slug: "ax" },
|
||||
{ name: "نظافتی و سلامتی", slug: "cleanliness-and-health" },
|
||||
{ name: "باتری", slug: "battery" },
|
||||
{ name: "بالش", slug: "pillow" },
|
||||
{ name: "امداد و نجات", slug: "amdad-o-ngat" },
|
||||
{ name: "لوازم جانبی", slug: "accessories" },
|
||||
{ name: "بخاری", slug: "heater" },
|
||||
{ name: "کیسه آب و کمل بک", slug: "kamel-beck" },
|
||||
{ name: "شعله افکن", slug: "flame-thrower" },
|
||||
{ name: "قطب نما", slug: "compass" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "صخره و یخ",
|
||||
slug: "rock-and-ice",
|
||||
children: [
|
||||
{ name: "طناب", slug: "rope" },
|
||||
{ name: "کارابین", slug: "carabiner" },
|
||||
{ name: "یخ شکن", slug: "crampons" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const categories: Category[] = [
|
||||
{ name: "اجاق گاز", english: "STOVE", slug: "travel-stove", image: "/images/categories/stove.png" },
|
||||
{ name: "تیشرت", english: "T-SHIRT", slug: "t-shirt", image: "/images/categories/tshirt.png" },
|
||||
{ name: "شلوار", english: "PANTS", slug: "pants", image: "/images/categories/pants.png" },
|
||||
{ name: "چاقو", english: "KNIFE", slug: "knife", image: "/images/categories/knife.png" },
|
||||
{ name: "کفش", english: "SHOES", slug: "shoe", image: "/images/categories/shoe.png" },
|
||||
{ name: "کلاه", english: "HAT", slug: "hat", image: "/images/categories/hat.png" },
|
||||
{ name: "هدلایت", english: "HEADLAMP", slug: "flashlight-and-headlights", image: "/images/categories/headlamp.png" },
|
||||
{ name: "ظروف", english: "COOKWARE", slug: "containers", image: "/images/categories/cookware.png" },
|
||||
{ name: "چادر", english: "TENT", slug: "sleeping-tent", image: "/images/categories/tent.png" },
|
||||
{ name: "باتوم", english: "POLES", slug: "mountaineering-baton", image: "/images/categories/pole.png" },
|
||||
{ name: "کیسه خواب", english: "SLEEPING BAG", slug: "sleeping-bag", image: "/images/categories/sleeping-bag.png" },
|
||||
{ name: "کوله پشتی", english: "BACKPACK", slug: "backpack", image: "/images/categories/backpack.png" },
|
||||
];
|
||||
|
||||
export const specialSaleProducts: Product[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "چراغ سیار خودرو یین زین مدل YX-B067",
|
||||
slug: "yin-xin-yx-b067",
|
||||
price: 490000,
|
||||
oldPrice: 550000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "دستمال سر و گردن زمستانی پیچی جیر",
|
||||
slug: "winter-neck-scarf",
|
||||
price: 290000,
|
||||
oldPrice: 400000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "کیف کمک های اولیه سالار SALAR",
|
||||
slug: "salar-first-aid-kit",
|
||||
price: 405000,
|
||||
oldPrice: 450000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "قاشق چنگال سفری مدل کارابین دار",
|
||||
slug: "carabiner-travel-cutlery",
|
||||
price: 490000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "قاشق چنگال مسافرتی تاشو مدل ارتشی",
|
||||
slug: "army-folding-cutlery",
|
||||
price: 550000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "گل برف باتوم کوهنوردی",
|
||||
slug: "trekking-pole-snow-basket",
|
||||
price: 96800,
|
||||
oldPrice: 110000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "کلاه طوفان پلار هپی اوتدور",
|
||||
slug: "happy-outdoor-storm-cap",
|
||||
price: 450000,
|
||||
oldPrice: 560000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "کلاه پلار نورث فیس",
|
||||
slug: "north-face-polar-hat",
|
||||
price: 745000,
|
||||
oldPrice: 1260000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "محافظ سرسخمه باتوم مدل کلاسیک",
|
||||
slug: "classic-pole-tip-protector",
|
||||
price: 70400,
|
||||
oldPrice: 80000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "سرسخمه یدکی باتوم",
|
||||
slug: "spare-trekking-pole-tip",
|
||||
price: 176000,
|
||||
oldPrice: 220000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
name: "گل سنگ باتوم کوهنوردی",
|
||||
slug: "trekking-pole-mud-basket",
|
||||
price: 59500,
|
||||
oldPrice: 70000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
];
|
||||
|
||||
export const topSellingProducts: Product[] = [
|
||||
{
|
||||
id: "ts-1",
|
||||
name: "طناب پاراکورد فینو طرح ساده (۲۰متر)",
|
||||
slug: "paracord-fino-20m",
|
||||
price: 420000,
|
||||
stock: 1,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-2",
|
||||
name: "کلاه زمستانی مدل شکاری طرح پوست",
|
||||
slug: "hunter-winter-hat",
|
||||
price: 250000,
|
||||
stock: 1,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-3",
|
||||
name: "صندل آب نوردی (ساحلی) نسیم مدل کلمبیا",
|
||||
slug: "nasim-columbia-sandals",
|
||||
price: 400000,
|
||||
stock: 2,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-4",
|
||||
name: "کلاه روسی طرح پوست",
|
||||
slug: "russian-hat",
|
||||
price: 250000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-5",
|
||||
name: "کلاه پلار ۵.۱۱",
|
||||
slug: "polar-hat-511",
|
||||
price: 350000,
|
||||
stock: 0,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-6",
|
||||
name: "پانچو نایلونی اوا EVA (بارانی)",
|
||||
slug: "eva-raincoat",
|
||||
price: 350000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-7",
|
||||
name: "کلاه پلار نورث فیس",
|
||||
slug: "north-face-polar-hat",
|
||||
price: 745000,
|
||||
oldPrice: 1260000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-8",
|
||||
name: "لیوان تاشو لایت مای فایر BACK UP CUP",
|
||||
slug: "light-my-fire-backup-cup",
|
||||
price: 0,
|
||||
stock: 0,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-9",
|
||||
name: "کارابین پیچ c1 (غیر فنی)",
|
||||
slug: "carabiner-c1",
|
||||
price: 130000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-10",
|
||||
name: "آفتابه مسافرتی A2",
|
||||
slug: "travel-aftabeh-a2",
|
||||
price: 120000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
];
|
||||
|
||||
export const banners4 = [
|
||||
{
|
||||
href: "/products/camping-and-mountaineering",
|
||||
image: PLACEHOLDER.banners4[0],
|
||||
title: "کمپینگ و کوهنوردی",
|
||||
english: "CAMP & CLIMB",
|
||||
subtitle: "تجهیزات طبیعتگردی",
|
||||
},
|
||||
{
|
||||
href: "/products/clothing",
|
||||
image: PLACEHOLDER.banners4[1],
|
||||
title: "پوشاک",
|
||||
english: "TRAIL APPAREL",
|
||||
subtitle: "لباس مناسب هر مسیر",
|
||||
},
|
||||
{
|
||||
href: "/products/shoe",
|
||||
image: PLACEHOLDER.banners4[2],
|
||||
title: "کفش",
|
||||
english: "FOOTWEAR",
|
||||
subtitle: "کفش کوه و طبیعت",
|
||||
},
|
||||
{
|
||||
href: "/products/rock-and-ice",
|
||||
image: PLACEHOLDER.banners4[3],
|
||||
title: "صخره و یخ",
|
||||
english: "ROCK & ICE",
|
||||
subtitle: "تجهیزات فنی صعود",
|
||||
},
|
||||
];
|
||||
|
||||
export const banners3 = [
|
||||
{ href: "/products", image: PLACEHOLDER.banners3[0], alt: "همه محصولات سالار استور" },
|
||||
{ href: "/special-sale", image: PLACEHOLDER.banners3[1], alt: "فروش ویژه سالار استور" },
|
||||
{ href: "/festival", image: PLACEHOLDER.banners3[2], alt: "جشنواره سالار استور" },
|
||||
];
|
||||
|
||||
export const brands = [
|
||||
{ name: "Deuter", image: "/images/brands/deuter.png" },
|
||||
{ name: "The North Face", image: "/images/brands/north-face.png" },
|
||||
{ name: "Pekvnew", image: "/images/brands/pekvnew.webp" },
|
||||
{ name: "Scarpa", image: "/images/brands/scarpa.png" },
|
||||
{ name: "Mammut", image: "/images/brands/mammut.png" },
|
||||
{ name: "Salewa", image: "/images/brands/salewa.png" },
|
||||
{ name: "Gerber", image: "/images/brands/gerber.png" },
|
||||
{ name: "Salomon", image: "/images/brands/salomon.png" },
|
||||
{ name: "Jack Wolfskin", image: "/images/brands/jack-wolfskin.png" },
|
||||
{ name: "Armour", image: "/images/brands/armour.png" },
|
||||
{ name: "Wenger", image: "/images/brands/wenger.png" },
|
||||
{ name: "Petzl", image: "/images/brands/petzl.png" },
|
||||
];
|
||||
|
||||
export const posts = [
|
||||
{ slug: "camelbak", title: "کمل بک چیست و چه کاربردی دارد؟", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "mountaineering-side-effects", title: "عوارض کوهنوردی برای زنان و مردان", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "trekking-pole-parts", title: "معرفی اجزای باتوم کوهنوردی", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "sunglasses-guide", title: "نحوه انتخاب عینک آفتابی کوهنوردی", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "sleeping-bag-sleep", title: "نحوه خوابیدن در کیسه خواب", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "avalanche", title: "بهمن چیست و چگونه به وجود میآید؟", date: "۱۴۰۲/۱۰/۱۶", image: PLACEHOLDER.blog },
|
||||
{ slug: "survival-kit", title: "راهنمای ساخت کیت بقا کوهنوردی", date: "۱۴۰۲/۰۹/۲۷", image: PLACEHOLDER.blog },
|
||||
{ slug: "stove-buying-guide", title: "فاکتورهای مهم در خرید سرشعله کوهنوردی", date: "۱۴۰۲/۰۹/۲۷", image: PLACEHOLDER.blog },
|
||||
];
|
||||
|
||||
export const footerLinks = {
|
||||
pages: [
|
||||
{ name: "درباره ما", href: "/aboutus" },
|
||||
{ name: "تماس با ما", href: "/contactus" },
|
||||
{ name: "محصولات", href: "/products" },
|
||||
{ name: "وبلاگ سالار استور", href: "/blog" },
|
||||
],
|
||||
support: [
|
||||
{ name: "سوالات متداول", href: "/frequently-asked-questions" },
|
||||
{ name: "راهنمای خرید از سالار استور", href: "/shopping-help" },
|
||||
{ name: "نحوه ارسال و مرجوع", href: "/method-sending-returning" },
|
||||
{ name: "هزینههای ارسال", href: "/shipping-cost" },
|
||||
],
|
||||
bestsellers: [
|
||||
{ name: "گل برف باتوم کوهنوردی", href: "/products/trekking-pole-snow-basket" },
|
||||
{ name: "کلاه پلار نورث فیس", href: "/products/north-face-polar-hat" },
|
||||
{ name: "چاقو گربر مدل ۱۱۴", href: "/products/gerber-114" },
|
||||
{ name: "پانچو یا بارانی نایلونی", href: "/products/eva-raincoat" },
|
||||
],
|
||||
};
|
||||
|
||||
export const features = [
|
||||
{ title: "ارسال از ۲ روز کاری", image: "/images/icons/shipping.webp" },
|
||||
{ title: "ضمانت اصل بودن کالا", image: "/images/icons/guarantee.webp" },
|
||||
{ title: "پشتیبانی ۷ روز هفته", image: "/images/icons/support.webp" },
|
||||
{ title: "ارسال به سراسر ایران", image: "/images/icons/nationwide.webp" },
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatPrice(value: number, locale: "en-US" | "fa-IR" = "fa-IR") {
|
||||
return value.toLocaleString(locale);
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("fa-IR");
|
||||
}
|
||||
|
||||
export function discountPercent(price: number, oldPrice?: number) {
|
||||
if (!oldPrice || oldPrice <= price) return 0;
|
||||
return Math.round(((oldPrice - price) / oldPrice) * 100);
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { PLACEHOLDER, type Post, type Product } from "./data";
|
||||
import {
|
||||
getBlogs,
|
||||
getProducts,
|
||||
getStoreSpecials,
|
||||
resolveProductSlug,
|
||||
type ApiBlog,
|
||||
type ApiProduct,
|
||||
type ApiSpecialItem,
|
||||
type ApiVariant,
|
||||
} from "./api";
|
||||
import { formatDate } from "./format";
|
||||
|
||||
function productImage(url?: string | null) {
|
||||
return url || PLACEHOLDER.product;
|
||||
}
|
||||
|
||||
function pickVariantPrice(variants: ApiVariant[]) {
|
||||
if (!variants.length) {
|
||||
return { price: 0, oldPrice: undefined as number | undefined, stock: 0 };
|
||||
}
|
||||
|
||||
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
|
||||
const pool = inStock.length ? inStock : variants;
|
||||
const best = pool.reduce((current, next) => {
|
||||
const currentPrice = current.discountedPrice ?? current.price;
|
||||
const nextPrice = next.discountedPrice ?? next.price;
|
||||
return nextPrice < currentPrice ? next : current;
|
||||
});
|
||||
|
||||
const price = best.discountedPrice ?? best.price ?? 0;
|
||||
const oldPrice = best.discountedPrice ? best.price : undefined;
|
||||
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
|
||||
|
||||
return { price, oldPrice, stock };
|
||||
}
|
||||
|
||||
export function mapProduct(item: ApiProduct): Product {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.nameFa || item.title,
|
||||
slug: item.slug,
|
||||
price: item.store?.minPrice ?? 0,
|
||||
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
|
||||
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
|
||||
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
|
||||
return {
|
||||
id: item.productId,
|
||||
name: item.productNameFa || item.productTitle,
|
||||
slug,
|
||||
price,
|
||||
oldPrice,
|
||||
stock,
|
||||
image: productImage(item.productImage),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBlog(item: ApiBlog): Post {
|
||||
return {
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
date: formatDate(item.publishedAt || item.createdAt || ""),
|
||||
image: item.titleImageUrl || PLACEHOLDER.blog,
|
||||
abstract: item.abstract?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpecialProducts(key: string): Promise<Product[]> {
|
||||
try {
|
||||
const specials = await getStoreSpecials();
|
||||
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
|
||||
if (!special) return [];
|
||||
|
||||
const items = special.items
|
||||
.filter((item) => item.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
|
||||
return mapSpecialItem(item, slug);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestPosts(pageSize = 12): Promise<Post[]> {
|
||||
try {
|
||||
const data = await getBlogs({ page: 1, pageSize });
|
||||
return data.items.map(mapBlog);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMappedProducts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await getProducts(query);
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapProduct),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: query.page ?? 1,
|
||||
pageSize: query.pageSize ?? 12,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMappedProductsForCategory(
|
||||
categoryId: string,
|
||||
childIds: string[],
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) {
|
||||
const ids = [categoryId, ...childIds];
|
||||
if (ids.length === 1) {
|
||||
return listMappedProducts({ page, pageSize, categoryId });
|
||||
}
|
||||
|
||||
const results = await Promise.all(ids.map((id) => listMappedProducts({ page: 1, pageSize: 50, categoryId: id })));
|
||||
const seen = new Set<string>();
|
||||
const items = results.flatMap((result) => result.items).filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: items.slice(start, start + pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import bradenCollum from "@/src/assets/images/samples/braden-collum-9HI8UJMSdZA-unsplash.jpg";
|
||||
import fabioComparelli from "@/src/assets/images/samples/fabio-comparelli-uq2E2V4LhCY-unsplash.jpg";
|
||||
import glenJackson from "@/src/assets/images/samples/glen-jackson-mzZVGFfMOkA-unsplash.jpg";
|
||||
import henryXu from "@/src/assets/images/samples/henry-xu-0XHo26Jb8dk-unsplash.jpg";
|
||||
import jesseGardner from "@/src/assets/images/samples/jesse-gardner-wTVr4HR4SBI-unsplash.jpg";
|
||||
import jiscaLucia from "@/src/assets/images/samples/jisca-lucia-HVTVOWDKTCk-unsplash.jpg";
|
||||
import kevinSchmid from "@/src/assets/images/samples/kevin-schmid-AvEY5UPoJZM-unsplash.jpg";
|
||||
import paulGreen from "@/src/assets/images/samples/paul-green-gOHfFgwyDNM-unsplash.jpg";
|
||||
import phillipsJacobe from "@/src/assets/images/samples/phillips-jacobe-3Oq0jNYLp28-unsplash.jpg";
|
||||
import seanBenesh from "@/src/assets/images/samples/sean-benesh-VnmbcgAfL3Q-unsplash.jpg";
|
||||
import sylvainMauroux from "@/src/assets/images/samples/sylvain-mauroux-OIuzDRYA3cw-unsplash.jpg";
|
||||
import victorLarracuente from "@/src/assets/images/samples/victor-larracuente-JQfYGhUcDSg-unsplash.jpg";
|
||||
|
||||
export const samples = {
|
||||
glenJackson,
|
||||
henryXu,
|
||||
jesseGardner,
|
||||
jiscaLucia,
|
||||
fabioComparelli,
|
||||
sylvainMauroux,
|
||||
phillipsJacobe,
|
||||
paulGreen,
|
||||
victorLarracuente,
|
||||
seanBenesh,
|
||||
kevinSchmid,
|
||||
bradenCollum,
|
||||
} as const;
|
||||
|
||||
export const sampleList: StaticImageData[] = Object.values(samples);
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
export const DOMAIN = "salarstore.com";
|
||||
|
||||
export const API = {
|
||||
base: "https://api.meshkee.com/api/v1",
|
||||
domain: DOMAIN,
|
||||
};
|
||||
|
||||
export const SITE = {
|
||||
nameFa: "سالار استور",
|
||||
nameEn: "SALAR STORE",
|
||||
tagline: "فروشگاه لوازم ورزشی و کوهنوردی",
|
||||
domain: DOMAIN,
|
||||
customerUrl: `https://customer.${DOMAIN}`,
|
||||
phone: "09127491700",
|
||||
phoneDisplay: "0912 749 1700",
|
||||
address: "قم، بسیج (هنرستان)، نبش کوچه ۹",
|
||||
instagram: "https://www.instagram.com/salarsport1354/",
|
||||
instagramHandle: "salar.sport1354",
|
||||
telegram: "https://t.me/salarsport1354",
|
||||
telegramHandle: "salarkooh",
|
||||
whatsapp: "https://wa.me/09127491700",
|
||||
email: "info@salarstore.com",
|
||||
meshkeeUrl: "https://meshkee.com",
|
||||
founded: 1394,
|
||||
about:
|
||||
"فروشگاه لوازم ورزشی سالار از سال ۱۳۹۴ فعالیت خود را شروع کرد. مدیر فروشگاه حسین سالاری مربی کوهپیمایی، یخنوردی و کوهنوردی از فدراسیون کوهنوردی ایران میباشد. هدف ما سلامتی و آرامش ورزشکاران است، به همین دلیل ما در سالار استور میکوشیم تا بهترین اجناس و محصولات را برای شما ورزشکاران عزیز فراهم کنیم. در این راه امیدواریم کمک کوچکی به جامعه کوهنوردی و ورزش ایران کرده باشیم.",
|
||||
aboutShort:
|
||||
"سالار استور همگام شما در ورزش با بیش از ۸ سال تجربه و فروش در حوزه ورزشی و خدمترسانی به ورزشکاران.",
|
||||
};
|
||||
Reference in New Issue
Block a user