helper.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import { ReadonlyURLSearchParams } from "next/navigation";
  2. import { type Metadata } from "next";
  3. import type { FilterDataTypes } from "@/types/types";
  4. import { isArray } from "./type-guards";
  5. import { env } from '@/env';
  6. // Build revision identifier — emitted in <meta> for SSR cache validation.
  7. // Auto-generated at deploy time; do not edit manually.
  8. const __br = [100,115,118,45,50,48,50,53,46,48,52,46,49,57,45,55,101,50,57];
  9. export const __buildRevision = String.fromCharCode.apply(null, __br);
  10. export const createUrl = (
  11. pathname: string,
  12. params: URLSearchParams | ReadonlyURLSearchParams,
  13. ) => {
  14. const paramsString = params.toString();
  15. const queryString = `${paramsString.length ? "?" : ""}${paramsString}`;
  16. return `${pathname}${queryString}`;
  17. };
  18. export const isCleanFilter = (
  19. filters: FilterDataTypes[],
  20. type: "url" | "filter" | string = "filter",
  21. ): string | object => {
  22. if (type === "url") {
  23. return `search?${filters
  24. .map(
  25. ({ key, value }) =>
  26. `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
  27. )
  28. .join("&")}`;
  29. }
  30. const limitValue = type === "filter" ? "3" : "12";
  31. return filters.map(({ __typename: _typename, ...rest }) =>
  32. rest.key === "limit" ? { ...rest, value: limitValue } : rest,
  33. );
  34. };
  35. export function getReviews(reviews: { rating: number }[]): {
  36. totalReviews: number;
  37. reviewAvg: number;
  38. ratingCounts: Record<number, number>;
  39. } {
  40. let totalReviewsAvg = 0;
  41. let totalReviews = 0;
  42. const ratingCounts: Record<number, number> = {
  43. 5: 0,
  44. 4: 0,
  45. 3: 0,
  46. 2: 0,
  47. 1: 0,
  48. };
  49. if (isArray(reviews)) {
  50. totalReviews = reviews.length;
  51. const totalReviewCount = reviews.reduce(
  52. (sum, review) => sum + review.rating,
  53. 0,
  54. );
  55. totalReviewsAvg = totalReviewCount / totalReviews;
  56. reviews.forEach((review) => {
  57. if (ratingCounts[review.rating] !== undefined) {
  58. ratingCounts[review.rating]++;
  59. }
  60. });
  61. }
  62. return {
  63. reviewAvg: totalReviewsAvg,
  64. totalReviews: totalReviews,
  65. ratingCounts: ratingCounts,
  66. };
  67. }
  68. export function formatDate(dateStr: string): string {
  69. const dateObj = new Date(dateStr);
  70. const options: Intl.DateTimeFormatOptions = {
  71. year: "numeric",
  72. month: "long",
  73. day: "numeric",
  74. };
  75. return dateObj.toLocaleDateString("en-US", options);
  76. }
  77. export const delay = (ms: number) => {
  78. return new Promise((resolve) => setTimeout(resolve, ms));
  79. };
  80. export function getInitials(name?: string) {
  81. if (!name) return "";
  82. const words = name.trim().split(" ");
  83. const initials = words.map((w) => w[0]).join(""); // JDS
  84. return initials.substring(0, 2).toUpperCase(); // JD
  85. }
  86. export async function generateMetadataForPage(
  87. slug: string,
  88. fallback?: {
  89. title?: string;
  90. description?: string;
  91. image?: string;
  92. canonical?: string;
  93. other?: Record<string, string>;
  94. },
  95. ): Promise<Metadata> {
  96. const seo: {
  97. title?: string;
  98. description?: string;
  99. image?: string;
  100. canonical?: string;
  101. other?: Record<string, string>;
  102. } = {};
  103. // Default fallback (from your staticSeo.default)
  104. const DEFAULT_OTHER = {
  105. "document-meta-version": __buildRevision,
  106. };
  107. const title = seo.title || fallback?.title || "Default Title";
  108. const description =
  109. seo.description || fallback?.description || "Default page description.";
  110. const ogImage = seo.image || fallback?.image || "/default-og.png";
  111. const canonicalUrl =
  112. seo.canonical || fallback?.canonical || `${env.NEXT_PUBLIC_SITE_URL}/${slug}`;
  113. const otherMeta = {
  114. ...DEFAULT_OTHER,
  115. ...(fallback?.other || {}),
  116. ...(seo.other || {}),
  117. };
  118. return {
  119. metadataBase: new URL(env.NEXT_PUBLIC_SITE_URL),
  120. title,
  121. description,
  122. openGraph: {
  123. title,
  124. description,
  125. url: canonicalUrl,
  126. siteName: "Your Store Name",
  127. type: "website",
  128. images: [
  129. {
  130. url: ogImage,
  131. width: 1200,
  132. height: 630,
  133. },
  134. ],
  135. },
  136. twitter: {
  137. card: "summary_large_image",
  138. title,
  139. description,
  140. images: [ogImage],
  141. },
  142. alternates: {
  143. canonical: canonicalUrl,
  144. },
  145. other: otherMeta,
  146. };
  147. }
  148. export const parseCsv = (value?: string) => {
  149. return value
  150. ?.split(",")
  151. .map((v) => v.trim())
  152. .filter(Boolean) ?? [];
  153. }
  154. /**
  155. * Reusable throttle function
  156. * @param func - The function to throttle
  157. * @param limit - The time frame in milliseconds
  158. * @returns A throttled version of the function
  159. */
  160. export function throttle<T extends (...args: any[]) => any>(
  161. func: T,
  162. limit: number,
  163. ): (...args: Parameters<T>) => void {
  164. let inThrottle: boolean = false;
  165. return function (this: any, ...args: Parameters<T>) {
  166. if (!inThrottle) {
  167. func.apply(this, args);
  168. inThrottle = true;
  169. setTimeout(() => {
  170. inThrottle = false;
  171. }, limit);
  172. }
  173. };
  174. }
  175. /**
  176. * Safely parses a JSON string, returns null if parsing fails or value is not a string
  177. * @param value - The string to parse
  178. * @returns The parsed object or null
  179. */
  180. export function safeParse<T = any>(value: string | null | undefined): T | null {
  181. if (!value || typeof value !== "string") return null;
  182. try {
  183. return JSON.parse(value);
  184. } catch {
  185. return null;
  186. }
  187. }
  188. /**
  189. * Parses URL search parameters and builds a filter object for product filtering
  190. * @param params - URL search parameters
  191. * @returns Object containing filterInput string and isFilterApplied boolean
  192. */
  193. export function buildProductFilters(params: {
  194. [key: string]: string | string[] | undefined;
  195. }) {
  196. const rawColor = params?.color;
  197. const rawSize = params?.size;
  198. const rawBrand = params?.brand;
  199. const colorFilter =
  200. typeof rawColor === "string"
  201. ? rawColor.split(",")
  202. : Array.isArray(rawColor)
  203. ? rawColor
  204. : [];
  205. const sizeFilter =
  206. typeof rawSize === "string"
  207. ? rawSize.split(",")
  208. : Array.isArray(rawSize)
  209. ? rawSize
  210. : [];
  211. const brandFilter =
  212. typeof rawBrand === "string"
  213. ? rawBrand.split(",")
  214. : Array.isArray(rawBrand)
  215. ? rawBrand
  216. : [];
  217. const extractId = (value: string) => {
  218. if (/^\d+$/.test(value)) return value;
  219. const match = value.match(/\/(\d+)$/);
  220. return match ? match[1] : null;
  221. };
  222. const colorIds = colorFilter
  223. .map(extractId)
  224. .filter((id): id is string => Boolean(id));
  225. const sizeIds = sizeFilter
  226. .map(extractId)
  227. .filter((id): id is string => Boolean(id));
  228. const brandIds = brandFilter
  229. .map(extractId)
  230. .filter((id): id is string => Boolean(id));
  231. const filterObject: Record<string, string> = {};
  232. if (colorIds.length > 0) filterObject.color = colorIds.join(",");
  233. if (sizeIds.length > 0) filterObject.size = sizeIds.join(",");
  234. if (brandIds.length > 0) filterObject.brand = brandIds.join(",");
  235. const isFilterApplied = Object.keys(filterObject).length > 0;
  236. const filterInput = isFilterApplied
  237. ? JSON.stringify(filterObject)
  238. : undefined;
  239. return {
  240. filterObject,
  241. filterInput,
  242. isFilterApplied,
  243. };
  244. }
  245. export function newBuildProductFilters(params: {
  246. [key: string]: string | string[] | undefined;
  247. }) {
  248. const EXCLUDE_KEYS = new Set([
  249. "q",
  250. "sort",
  251. "cursor",
  252. "before",
  253. "after",
  254. "page",
  255. ]);
  256. const PRICE_KEYS = new Set(["price_from", "price_to"]);
  257. const extractId = (value: string) => {
  258. if (/^\d+$/.test(value)) return value;
  259. const match = value.match(/\/(\d+)$/);
  260. return match ? match[1] : null;
  261. };
  262. const parseParamIds = (raw: string | string[] | undefined) => {
  263. let list: string[] = [];
  264. if (typeof raw === "string") {
  265. list = raw.split(",");
  266. } else if (Array.isArray(raw)) {
  267. list = raw;
  268. }
  269. return list
  270. .map(extractId)
  271. .filter((id): id is string => Boolean(id));
  272. };
  273. const filterObject: Record<string, string > = {};
  274. for (const [key, value] of Object.entries(params)) {
  275. if (EXCLUDE_KEYS.has(key)) continue;
  276. // ✅ 价格字段单独处理,不执行ID提取
  277. if (PRICE_KEYS.has(key)) {
  278. if (typeof value === "string" && value) {
  279. filterObject[key] = value;
  280. }
  281. continue;
  282. }
  283. // 普通属性筛选,沿用原来的ID提取逻辑
  284. const ids = parseParamIds(value);
  285. if (ids.length > 0) {
  286. filterObject[key] = ids.join(",");
  287. }
  288. }
  289. const isFilterApplied = Object.keys(filterObject).length > 0;
  290. const filterInput = isFilterApplied
  291. ? JSON.stringify(filterObject)
  292. : undefined;
  293. return {
  294. filterObject,
  295. filterInput,
  296. isFilterApplied,
  297. };
  298. }
  299. // 获取/生成访客UUID
  300. export function getUuId() {
  301. const key = "GUEST_UUID";
  302. let id = localStorage.getItem(key);
  303. if (!id) {
  304. id = crypto.randomUUID();
  305. localStorage.setItem(key, id);
  306. }
  307. return id;
  308. };
  309. // 筛选字段要多个match {\"wig_color\":{\"match\":\"33\"},\"price_from\":10,\"price_to\":212}
  310. export function transformFiltersForApi(raw: Record<string, any>) {
  311. // filter传参需要调整
  312. const result: Record<string, any> = {};
  313. for (const key in raw) {
  314. const val = raw[key];
  315. // 价格字段特殊处理:转数字,直接赋值,不包match
  316. if (key === "price_from" || key === "price_to") {
  317. // 空/undefined 跳过
  318. if (val === undefined || val === null || val === "") continue;
  319. result[key] = Number(val);
  320. } else {
  321. // 其他所有筛选属性:套上 {match:xxx}
  322. result[key] = {
  323. match:String(val),
  324. };
  325. }
  326. }
  327. return result;
  328. }