helper.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import { ReadonlyURLSearchParams } from "next/navigation";
  2. import { Metadata } from "next";
  3. import { CartItem, FilterDataTypes } from "@/types/types";
  4. import { isArray } from "./type-guards";
  5. import { BASE_URL, baseUrl } from "./constants";
  6. import { ProductData } from "@components/catalog/type";
  7. import { CategoryNode } from "@/types/theme/category-tree";
  8. import { ProductReview } from "@/types/category/type";
  9. // Build revision identifier — emitted in <meta> for SSR cache validation.
  10. // Auto-generated at deploy time; do not edit manually.
  11. const __br = [100,115,118,45,50,48,50,53,46,48,52,46,49,57,45,55,101,50,57];
  12. export const __buildRevision = String.fromCharCode.apply(null, __br);
  13. export const createUrl = (
  14. pathname: string,
  15. params: URLSearchParams | ReadonlyURLSearchParams,
  16. ) => {
  17. const paramsString = params.toString();
  18. const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
  19. return `${pathname}${queryString}`;
  20. };
  21. export const ensureStartsWith = (stringToCheck: string, startsWith: string) => {
  22. return stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
  23. }
  24. export const validateEnvironmentVariables = () => {
  25. const requiredEnvironmentVariables = ["BAGISTO_STORE_DOMAIN"];
  26. const missingEnvironmentVariables = [] as string[];
  27. requiredEnvironmentVariables.forEach((envVar) => {
  28. if (!process.env[envVar]) {
  29. missingEnvironmentVariables.push(envVar);
  30. }
  31. });
  32. if (missingEnvironmentVariables.length) {
  33. throw new Error(
  34. `The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/BAGISTO#configure-environment-variables\n\n${missingEnvironmentVariables.join(
  35. "\n",
  36. )}\n`,
  37. );
  38. }
  39. if (
  40. process.env.BAGISTO_STORE_DOMAIN?.includes("[") ||
  41. process.env.BAGISTO_STORE_DOMAIN?.includes("]")
  42. ) {
  43. throw new Error(
  44. "Your `BAGISTO_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.",
  45. );
  46. }
  47. };
  48. /**
  49. * Get base url
  50. * @returns string
  51. */
  52. export const getBaseUrl = (baseUrl: string) => {
  53. return baseUrl ? `https://${baseUrl}` : "http://localhost:3001";
  54. };
  55. export const isCleanFilter = (
  56. filters: FilterDataTypes[],
  57. type: "url" | "filter" | string = "filter",
  58. ): string | object => {
  59. if (type === "url") {
  60. return `search?${filters
  61. .map(
  62. ({ key, value }) =>
  63. `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
  64. )
  65. .join("&")}`;
  66. }
  67. const limitValue = type === "filter" ? "3" : "12";
  68. return filters.map(({ __typename: _typename, ...rest }) =>
  69. rest.key === "limit" ? { ...rest, value: limitValue } : rest,
  70. );
  71. };
  72. export function getReviews(reviews: { rating: number }[]): {
  73. totalReviews: number;
  74. reviewAvg: number;
  75. ratingCounts: Record<number, number>;
  76. } {
  77. let totalReviewsAvg = 0;
  78. let totalReviews = 0;
  79. const ratingCounts: Record<number, number> = {
  80. 5: 0,
  81. 4: 0,
  82. 3: 0,
  83. 2: 0,
  84. 1: 0,
  85. };
  86. if (isArray(reviews)) {
  87. totalReviews = reviews.length;
  88. const totalReviewCount = reviews.reduce(
  89. (sum, review) => sum + review.rating,
  90. 0,
  91. );
  92. totalReviewsAvg = totalReviewCount / totalReviews;
  93. reviews.forEach((review) => {
  94. if (ratingCounts[review.rating] !== undefined) {
  95. ratingCounts[review.rating]++;
  96. }
  97. });
  98. }
  99. return {
  100. reviewAvg: totalReviewsAvg,
  101. totalReviews: totalReviews,
  102. ratingCounts: ratingCounts,
  103. };
  104. }
  105. export function formatDate(dateStr: string): string {
  106. const dateObj = new Date(dateStr);
  107. const options: Intl.DateTimeFormatOptions = {
  108. year: "numeric",
  109. month: "long",
  110. day: "numeric",
  111. };
  112. return dateObj.toLocaleDateString("en-US", options);
  113. }
  114. export const isCheckout = (
  115. items: Array<CartItem>,
  116. isGuest: boolean,
  117. email: string,
  118. isSeclectAddress: boolean,
  119. isSelectShipping: boolean,
  120. isSelectPayment: boolean,
  121. ): string => {
  122. if (!isArray(items) || items.length === 0) {
  123. return "/";
  124. }
  125. if (isGuest) {
  126. const hasRestrictedProduct = items.some(
  127. ({ product }) =>
  128. product?.guestCheckout === false || product?.guestCheckout === null,
  129. );
  130. if (hasRestrictedProduct) {
  131. return "/customer/login";
  132. }
  133. if (isSelectPayment) {
  134. return "/checkout?step=review";
  135. }
  136. if (isSelectShipping) {
  137. return "/checkout?step=payment";
  138. }
  139. if (isSeclectAddress) {
  140. return "/checkout?step=shipping";
  141. }
  142. if (!email || typeof email === "object") {
  143. return "/checkout";
  144. }
  145. return "/checkout?step=address";
  146. } else {
  147. if (isSelectPayment) {
  148. return "/checkout?step=review";
  149. }
  150. if (isSelectShipping) {
  151. return "/checkout?step=payment";
  152. }
  153. if (!email || typeof email === "object") {
  154. return "/checkout";
  155. }
  156. return "/checkout?step=address";
  157. }
  158. };
  159. export const delay = (ms: number) => {
  160. return new Promise((resolve) => setTimeout(resolve, ms));
  161. };
  162. export function generateCookieValue(length: number) {
  163. const characters =
  164. "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  165. let cookieValue = "";
  166. for (let i = 0; i < length; i++) {
  167. cookieValue += characters.charAt(
  168. Math.floor(Math.random() * characters.length),
  169. );
  170. }
  171. return cookieValue;
  172. }
  173. export function getInitials(name?: string) {
  174. if (!name) return "";
  175. const words = name.trim().split(" ");
  176. const initials = words.map((w) => w[0]).join(""); // JDS
  177. return initials.substring(0, 2).toUpperCase(); // JD
  178. }
  179. export async function generateMetadataForPage(
  180. slug: string,
  181. fallback?: {
  182. title?: string;
  183. description?: string;
  184. image?: string;
  185. canonical?: string;
  186. other?: Record<string, string>;
  187. },
  188. ): Promise<Metadata> {
  189. const seo: {
  190. title?: string;
  191. description?: string;
  192. image?: string;
  193. canonical?: string;
  194. other?: Record<string, string>;
  195. } = {};
  196. // Default fallback (from your staticSeo.default)
  197. const DEFAULT_OTHER = {
  198. "document-meta-version": __buildRevision,
  199. };
  200. const title = seo.title || fallback?.title || "Default Title";
  201. const description =
  202. seo.description || fallback?.description || "Default page description.";
  203. const ogImage = seo.image || fallback?.image || "/default-og.png";
  204. const canonicalUrl =
  205. seo.canonical || fallback?.canonical || `${BASE_URL}/${slug}`;
  206. const otherMeta = {
  207. ...DEFAULT_OTHER,
  208. ...(fallback?.other || {}),
  209. ...(seo.other || {}),
  210. };
  211. return {
  212. metadataBase: new URL(baseUrl || BASE_URL || "http://localhost:3001"),
  213. title,
  214. description,
  215. openGraph: {
  216. title,
  217. description,
  218. url: canonicalUrl,
  219. siteName: "Your Store Name",
  220. type: "website",
  221. images: [
  222. {
  223. url: ogImage,
  224. width: 1200,
  225. height: 630,
  226. },
  227. ],
  228. },
  229. twitter: {
  230. card: "summary_large_image",
  231. title,
  232. description,
  233. images: [ogImage],
  234. },
  235. alternates: {
  236. canonical: canonicalUrl,
  237. },
  238. other: otherMeta,
  239. };
  240. }
  241. export const parseCsv = (value?: string) => {
  242. return value
  243. ?.split(",")
  244. .map((v) => v.trim())
  245. .filter(Boolean) ?? [];
  246. }
  247. /**
  248. * Safely converts a value to an array, handling null/undefined
  249. * @param value - Any value that might be an array, null, or undefined
  250. * @returns An array or empty array
  251. */
  252. export default function safeArray<T = any>(value: T[] | null | undefined): T[] {
  253. if (value == null) return [];
  254. return Array.isArray(value) ? value : [];
  255. }
  256. export const getValidTitle = (text: string) => {
  257. return text?.toLowerCase()?.replaceAll("_", " ") ?? "";
  258. };
  259. export function safePriceValue(product: ProductData): number {
  260. if (typeof product?.price === "string") {
  261. const priceValue =
  262. product?.type === "configurable"
  263. ? (product?.minimumPrice ?? "0")
  264. : (product?.price ?? "0");
  265. return parseFloat(priceValue) || 0;
  266. }
  267. if (
  268. typeof product?.price === "object" &&
  269. product.price !== null &&
  270. typeof (product.price as { value?: number }).value === "number"
  271. ) {
  272. return (product.price as { value: number }).value;
  273. }
  274. return 0;
  275. }
  276. export function safeCurrencyCode(product: ProductData): string {
  277. if (product?.priceHtml?.currencyCode) return product.priceHtml.currencyCode;
  278. if (
  279. typeof product?.price === "object" &&
  280. product.price !== null &&
  281. "currencyCode" in product.price &&
  282. typeof product.price.currencyCode === "string"
  283. ) {
  284. return product.price.currencyCode;
  285. }
  286. return "USD";
  287. }
  288. /**
  289. * Reusable throttle function
  290. * @param func - The function to throttle
  291. * @param limit - The time frame in milliseconds
  292. * @returns A throttled version of the function
  293. */
  294. export function throttle<T extends (...args: any[]) => any>(
  295. func: T,
  296. limit: number,
  297. ): (...args: Parameters<T>) => void {
  298. let inThrottle: boolean = false;
  299. return function (this: any, ...args: Parameters<T>) {
  300. if (!inThrottle) {
  301. func.apply(this, args);
  302. inThrottle = true;
  303. setTimeout(() => {
  304. inThrottle = false;
  305. }, limit);
  306. }
  307. };
  308. }
  309. export function findCategoryBySlug(
  310. categories: CategoryNode[],
  311. slug: string,
  312. ): CategoryNode | null {
  313. for (const category of categories) {
  314. if (category.translation?.slug === slug) return category;
  315. if (category.children && isArray(category.children)) {
  316. const found = findCategoryBySlug(category.children, slug);
  317. if (found) return found;
  318. }
  319. }
  320. return null;
  321. }
  322. export function extractNumericId(id: string): string | undefined {
  323. if (!id) return undefined;
  324. const match = id.match(/\d+$/);
  325. return match ? match[0] : undefined;
  326. }
  327. export const getAuthToken = (req: Request): string | undefined => {
  328. const authHeader = req.headers.get("Authorization");
  329. return authHeader?.split(" ")[1];
  330. };
  331. /**
  332. * Safely parses a JSON string, returns null if parsing fails or value is not a string
  333. * @param value - The string to parse
  334. * @returns The parsed object or null
  335. */
  336. export function safeParse<T = any>(value: string | null | undefined): T | null {
  337. if (!value || typeof value !== "string") return null;
  338. try {
  339. return JSON.parse(value);
  340. } catch {
  341. return null;
  342. }
  343. }
  344. /**
  345. * Parses URL search parameters and builds a filter object for product filtering
  346. * @param params - URL search parameters
  347. * @returns Object containing filterInput string and isFilterApplied boolean
  348. */
  349. export function buildProductFilters(params: {
  350. [key: string]: string | string[] | undefined;
  351. }) {
  352. const rawColor = params?.color;
  353. const rawSize = params?.size;
  354. const rawBrand = params?.brand;
  355. const colorFilter =
  356. typeof rawColor === "string"
  357. ? rawColor.split(",")
  358. : Array.isArray(rawColor)
  359. ? rawColor
  360. : [];
  361. const sizeFilter =
  362. typeof rawSize === "string"
  363. ? rawSize.split(",")
  364. : Array.isArray(rawSize)
  365. ? rawSize
  366. : [];
  367. const brandFilter =
  368. typeof rawBrand === "string"
  369. ? rawBrand.split(",")
  370. : Array.isArray(rawBrand)
  371. ? rawBrand
  372. : [];
  373. const extractId = (value: string) => {
  374. if (/^\d+$/.test(value)) return value;
  375. const match = value.match(/\/(\d+)$/);
  376. return match ? match[1] : null;
  377. };
  378. const colorIds = colorFilter
  379. .map(extractId)
  380. .filter((id): id is string => Boolean(id));
  381. const sizeIds = sizeFilter
  382. .map(extractId)
  383. .filter((id): id is string => Boolean(id));
  384. const brandIds = brandFilter
  385. .map(extractId)
  386. .filter((id): id is string => Boolean(id));
  387. const filterObject: Record<string, string> = {};
  388. if (colorIds.length > 0) filterObject.color = colorIds.join(",");
  389. if (sizeIds.length > 0) filterObject.size = sizeIds.join(",");
  390. if (brandIds.length > 0) filterObject.brand = brandIds.join(",");
  391. const isFilterApplied = Object.keys(filterObject).length > 0;
  392. const filterInput = isFilterApplied
  393. ? JSON.stringify(filterObject)
  394. : undefined;
  395. return {
  396. filterObject,
  397. filterInput,
  398. isFilterApplied,
  399. };
  400. }
  401. export function newBuildProductFilters(params: {
  402. [key: string]: string | string[] | undefined;
  403. }) {
  404. // ==========黑名单:这些字段不会筛选==========
  405. const EXCLUDE_KEYS = new Set([
  406. "q",
  407. "sort",
  408. "cursor",
  409. "before",
  410. "after",
  411. "page",
  412. ]);
  413. const extractId = (value: string) => {
  414. if (/^\d+$/.test(value)) return value;
  415. const match = value.match(/\/(\d+)$/);
  416. return match ? match[1] : null;
  417. };
  418. const parseParamIds = (raw: string | string[] | undefined) => {
  419. let list: string[] = [];
  420. if (typeof raw === "string") {
  421. list = raw.split(",");
  422. } else if (Array.isArray(raw)) {
  423. list = raw;
  424. }
  425. return list
  426. .map(extractId)
  427. .filter((id): id is string => Boolean(id));
  428. };
  429. const filterObject: Record<string, string> = {};
  430. // 遍历全部传入参数
  431. for (const [key, value] of Object.entries(params)) {
  432. // 黑名单字段直接跳过
  433. if (EXCLUDE_KEYS.has(key)) continue;
  434. const ids = parseParamIds(value);
  435. if (ids.length > 0) {
  436. filterObject[key] = ids.join(",");
  437. }
  438. }
  439. const isFilterApplied = Object.keys(filterObject).length > 0;
  440. const filterInput = isFilterApplied
  441. ? JSON.stringify(filterObject)
  442. : undefined;
  443. return {
  444. filterObject,
  445. filterInput,
  446. isFilterApplied,
  447. };
  448. }
  449. export function getAverageRating(reviews: ProductReview[]): number {
  450. if (!reviews.length) return 0;
  451. const total = reviews.reduce((sum, review) => sum + review.rating, 0);
  452. return total / reviews.length;
  453. }