index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. import { cookies, headers } from "next/headers";
  2. import { revalidatePath } from "next/cache";
  3. import { NextRequest, NextResponse } from "next/server";
  4. import {
  5. BagistoCreateUserOperation,
  6. BagistoProductInfo,
  7. BagistoSession,
  8. BagistoUser,
  9. ImageInfo,
  10. } from "@/types/types";
  11. import {
  12. HIDDEN_PRODUCT_TAG,
  13. } from "../constants";
  14. import { getServerSession } from "next-auth";
  15. import {
  16. CUSTOMER_LOGOUT,
  17. CUSTOMER_REGISTRATION,
  18. FORGET_PASSWORD,
  19. } from "@/graphql/customer/mutations";
  20. import { print, DocumentNode } from "graphql";
  21. import {
  22. GRAPHQL_URL,
  23. REST_API_URL,
  24. } from "@/utils/constants/server";
  25. import {
  26. GUEST_CART_TOKEN,
  27. CURRENT_CURRENCY,
  28. CURRENT_LOCAL,
  29. CURRENT_CHANNEL,
  30. } from "@/utils/constants";
  31. import {
  32. GET_FOOTER,
  33. GET_THEME_CUSTOMIZATION,
  34. PAGE_BY_URL_KEY,
  35. } from "@/graphql";
  36. import { SUBSCRIBE_TO_NEWSLETTER } from "@/graphql/theme/mutations";
  37. import { cachedGraphQLRequest } from "@/utils/hooks/useCache";
  38. import { authOptions } from "@utils/auth";
  39. import { RegisterInputs } from "@components/customer/RegistrationForm";
  40. import {
  41. GetFooterResponse,
  42. ThemeCustomizationResult,
  43. ThemeCustomizationResponse,
  44. PageData,
  45. } from "@/types/theme/theme-customization";
  46. import {FetchGraphqlResult} from "@/types/graphqlFetch/type";
  47. type ExtractVariables<T> = T extends { variables: object }
  48. ? T["variables"]
  49. : any;
  50. type ExtractRestFulData<T> = T extends { data: infer D } ? D : any;
  51. type ExtractGraphqlData<T> = T extends { data: infer D } ? { data: D } : any;
  52. interface PageByUrlKeyResponse {
  53. pageByUrlKeypages?: PageData[];
  54. }
  55. const STOREFRONT_KEY = process.env.NEXT_PUBLIC_BAGISTO_STOREFRONT_KEY;
  56. async function getBaseHeader() {
  57. /**
  58. * Headers:
  59. * X-LOCALE — locale code, e.g. "en", "fr", "ar"
  60. * X-CHANNEL — channel code, e.g. "default"
  61. * X-CURRENCY — currency code, e.g. "USD", "EUR", "INR"
  62. * */
  63. if(!STOREFRONT_KEY) {
  64. throw new Error("STOREFRONT_KEY must be configed!");
  65. }
  66. const cookieStore = await cookies();
  67. const xCurrency = cookieStore.get(CURRENT_CURRENCY)?.value; // 在proxy.ts中已经设置过值了
  68. const xLocal = cookieStore.get(CURRENT_LOCAL)?.value; // 在proxy.ts中已经设置过值了
  69. const xChannel = cookieStore.get(CURRENT_CHANNEL)?.value; // 在proxy.ts中已经设置过值了
  70. const baseHeaders: Record<string, string> = {
  71. "Content-Type": "application/json",
  72. "X-STOREFRONT-KEY": STOREFRONT_KEY,
  73. };
  74. if(xCurrency) {
  75. baseHeaders["X-Currency"] = xCurrency;
  76. }
  77. if(xLocal) {
  78. baseHeaders["X-Local"] = xLocal;
  79. }
  80. if(xChannel) {
  81. baseHeaders["X-Channel"] = xChannel;
  82. }
  83. return baseHeaders;
  84. }
  85. export async function getAuthorizationToken(): Promise<{token: string | null; isGuest: boolean;}> {
  86. // 登录用户从nextAuth里获取
  87. const authSession = (await getServerSession(
  88. authOptions,
  89. )) as BagistoSession | null;
  90. const accessToken = authSession?.user?.accessToken;
  91. if (accessToken) {
  92. return {
  93. token: accessToken,
  94. isGuest: false,
  95. };
  96. }
  97. // 游客从cookie里获取
  98. const cookieStore = await cookies();
  99. const guestToken = cookieStore.get(GUEST_CART_TOKEN)?.value;
  100. return {
  101. token: guestToken || null,
  102. isGuest: true
  103. };
  104. }
  105. // rest api fetch
  106. export async function restApiFetch<T>({
  107. api,
  108. method,
  109. cache = "force-cache",
  110. headers,
  111. tags,
  112. variables,
  113. revalidate = 60,
  114. }: {
  115. api: string;
  116. method: "POST" | "GET" | "PUT" | "DELETE";
  117. cache?: RequestCache;
  118. headers?: HeadersInit | Record<string, string>;
  119. tags?: string[];
  120. variables?: ExtractVariables<T>;
  121. isCookies?: boolean;
  122. guestToken?: string;
  123. revalidate?: number;
  124. }): Promise<{ status: number; body: ExtractRestFulData<T> } | never> {
  125. try {
  126. const apiUrl = api.startsWith("http") ? api : `${REST_API_URL}${api}`;
  127. const url = new URL(apiUrl);
  128. const tokenRes = await getAuthorizationToken();
  129. const headerRes = await getBaseHeader();
  130. const baseHeaders: Record<string, string> = {...headerRes};
  131. /*
  132. let accessToken: string | undefined = undefined;
  133. if (isCookies) {
  134. const sessions = (await getServerSession(
  135. authOptions,
  136. )) as BagistoSession | null;
  137. accessToken = sessions?.user?.accessToken;
  138. }
  139. if (accessToken) {
  140. baseHeaders.Authorization = `Bearer ${accessToken}`;
  141. } else if (guestToken) {
  142. baseHeaders.Authorization = `Bearer ${guestToken}`;
  143. }
  144. */
  145. if (tokenRes.token) {
  146. baseHeaders.Authorization = `Bearer ${tokenRes.token}`;
  147. }
  148. if (headers) {
  149. if (headers instanceof Headers) {
  150. headers.forEach((value, key) => (baseHeaders[key] = value));
  151. } else {
  152. Object.assign(baseHeaders, headers);
  153. }
  154. }
  155. console.log('restApiFetch --- baseHeaders:', baseHeaders)
  156. const param: RequestInit = {
  157. method: method,
  158. headers: baseHeaders,
  159. cache,
  160. next: {
  161. revalidate: cache === "no-store" ? 0 : revalidate || 60,
  162. ...(tags && { tags }),
  163. },
  164. };
  165. if(variables && method === "POST") {
  166. param.body = JSON.stringify({...variables});
  167. }
  168. if(variables && method === "GET") {
  169. const entries = Object.entries(variables as Record<string, unknown>);
  170. for (const [key, val] of entries) {
  171. if (val !== undefined && val !== null) {
  172. url.searchParams.set(key, String(val));
  173. }
  174. }
  175. }
  176. const result = await fetch(url, param);
  177. const body = await result.json();
  178. console.log('restApiFetch --- body:', body)
  179. return { status: result.status, body };
  180. } catch (e) {
  181. throw e;
  182. }
  183. }
  184. // graphql fetch use for server side
  185. export async function serverGraphqlFetch<
  186. TData,
  187. TVariables = Record<string, never>
  188. >({
  189. cache = "no-store",
  190. headers,
  191. query,
  192. tags,
  193. variables,
  194. revalidate = 0,
  195. }: {
  196. cache?: RequestCache;
  197. headers?: HeadersInit | Record<string, string>;
  198. query: string | DocumentNode;
  199. tags?: string[];
  200. variables?: TVariables;
  201. revalidate?: number;
  202. }): Promise<FetchGraphqlResult<TData>> {
  203. try {
  204. const queryString = typeof query === "string" ? query : print(query);
  205. const tokenRes = await getAuthorizationToken();
  206. const headerRes = await getBaseHeader();
  207. const baseHeaders: Record<string, string> = {...headerRes};
  208. if (tokenRes.token) {
  209. baseHeaders['Authorization'] = `Bearer ${tokenRes.token}`;
  210. }
  211. if (headers) {
  212. if (headers instanceof Headers) {
  213. headers.forEach((value, key) => (baseHeaders[key] = value));
  214. } else {
  215. Object.assign(baseHeaders, headers);
  216. }
  217. }
  218. console.log('serverGraphqlFetch --- baseHeaders:', baseHeaders);
  219. const result = await fetch(GRAPHQL_URL, {
  220. method: "POST",
  221. headers: baseHeaders,
  222. body: JSON.stringify({
  223. query: queryString,
  224. ...(variables && { variables }),
  225. }),
  226. cache,
  227. next: {
  228. revalidate: cache === "no-store" ? 0 : revalidate || 60,
  229. ...(tags && { tags }),
  230. },
  231. });
  232. const body = await result.json();
  233. console.log('serverGraphqlFetch --- body:',body);
  234. return {
  235. status:result.status,
  236. data:body.data ?? null,
  237. error:body.errors?.[0] ?? null
  238. }
  239. } catch (e) {
  240. console.error( "GraphQL request failed", e );
  241. throw e;
  242. }
  243. }
  244. // graphql fetch use for route handler
  245. export async function bagistoFetch<T>({
  246. cache = "force-cache",
  247. headers,
  248. query,
  249. tags,
  250. variables,
  251. // isCookies = true,
  252. // guestToken,
  253. revalidate = 60,
  254. }: {
  255. cache?: RequestCache;
  256. headers?: HeadersInit | Record<string, string>;
  257. query: string | DocumentNode;
  258. tags?: string[];
  259. variables?: ExtractVariables<T>;
  260. // isCookies?: boolean;
  261. // guestToken?: string;
  262. revalidate?: number;
  263. }): Promise<{ status: number; body: ExtractGraphqlData<T> } | never> {
  264. try {
  265. const queryString =
  266. typeof query === "string" ? query : print(query);
  267. const tokenRes = await getAuthorizationToken();
  268. const headerRes = await getBaseHeader();
  269. const baseHeaders: Record<string, string> = {...headerRes};
  270. /*
  271. let accessToken: string | undefined = undefined;
  272. if (isCookies) {
  273. const sessions = (await getServerSession(
  274. authOptions,
  275. )) as BagistoSession | null;
  276. accessToken = sessions?.user?.accessToken;
  277. }
  278. if (accessToken) {
  279. baseHeaders.Authorization = `Bearer ${accessToken}`;
  280. } else if (guestToken) {
  281. baseHeaders.Authorization = `Bearer ${guestToken}`;
  282. }
  283. */
  284. if (tokenRes.token) {
  285. baseHeaders.Authorization = `Bearer ${tokenRes.token}`;
  286. }
  287. if (headers) {
  288. if (headers instanceof Headers) {
  289. headers.forEach((value, key) => (baseHeaders[key] = value));
  290. } else {
  291. Object.assign(baseHeaders, headers);
  292. }
  293. }
  294. // let cc = await cookies();
  295. // console.log('bagistoFetch --- url:', GRAPHQL_URL,isCookies);
  296. // console.log('bagistoFetch --- queryString:', queryString);
  297. console.log('bagistoFetch --- baseHeaders:', baseHeaders);
  298. // console.log('bagistoFetch --- cookies:', cc.getAll());
  299. // console.log('bagistoFetch --- variables:', variables);
  300. const result = await fetch(GRAPHQL_URL, {
  301. method: "POST",
  302. headers: baseHeaders,
  303. body: JSON.stringify({
  304. query: queryString,
  305. ...(variables && { variables }),
  306. }),
  307. cache,
  308. next: {
  309. revalidate: cache === "no-store" ? 0 : revalidate || 60,
  310. ...(tags && { tags }),
  311. },
  312. });
  313. const body = await result.json();
  314. console.log('bagistoFetch --- body:',body);
  315. return { status: result.status, body };
  316. } catch (e) {
  317. throw e;
  318. }
  319. }
  320. export const removeEdgesAndNodes = <T>(array: Array<T>) => {
  321. return array?.map((edge) => edge);
  322. };
  323. const reshapeImages = (images: Array<ImageInfo>, productTitle: string) => {
  324. const flattened = removeEdgesAndNodes(images);
  325. return flattened.map((image) => {
  326. const filename = image?.url.match(/.*\/(.*)\..*/)?.[1];
  327. return {
  328. ...image,
  329. altText: image?.altText || `${productTitle} - ${filename}`,
  330. };
  331. });
  332. };
  333. const reshapeProduct = (
  334. product: BagistoProductInfo,
  335. filterHiddenProducts: boolean = true,
  336. ) => {
  337. if (
  338. !product ||
  339. (filterHiddenProducts && product.tags?.includes(HIDDEN_PRODUCT_TAG))
  340. ) {
  341. return undefined;
  342. }
  343. const { images, variants, ...rest } = product;
  344. return {
  345. ...rest,
  346. images: reshapeImages(images, product.title),
  347. variants: removeEdgesAndNodes(variants),
  348. };
  349. };
  350. export const reshapeProducts = (products: BagistoProductInfo[]) => {
  351. const reshapedProducts = [];
  352. for (const product of products) {
  353. if (product) {
  354. const reshapedProduct = reshapeProduct(product);
  355. if (reshapedProduct) {
  356. reshapedProducts.push(reshapedProduct);
  357. }
  358. }
  359. }
  360. return reshapedProducts;
  361. };
  362. export async function createUserToLogin(
  363. input: RegisterInputs,
  364. ): Promise<BagistoUser> {
  365. try {
  366. const { passwordConfirmation, ...userInput } = input;
  367. const res = await bagistoFetch<BagistoCreateUserOperation>({
  368. query: CUSTOMER_REGISTRATION,
  369. variables: {
  370. input: {
  371. ...userInput,
  372. confirmPassword: passwordConfirmation,
  373. status: "1",
  374. isVerified: "1",
  375. isSuspended: "0",
  376. subscribedToNewsLetter: true,
  377. },
  378. },
  379. cache: "no-store",
  380. revalidate: 3600,
  381. });
  382. return res.body.data.createCustomer.customer;
  383. } catch (error: any) {
  384. throw new Error(error?.message || "Registration failed");
  385. }
  386. }
  387. export async function logoutUser() {
  388. try {
  389. const session = await getServerSession(authOptions);
  390. const token = session?.user?.accessToken;
  391. if (!token) {
  392. return {
  393. success: false,
  394. message: "User token missing",
  395. };
  396. }
  397. /**
  398. * @todo CUSTOMER_LOGOUT 接口会报错,待修复(确定是php后端接口报错)
  399. */
  400. const res = await bagistoFetch<{
  401. data: { createLogout: { logout: { success: boolean; message: string } } };
  402. variables: { input: { token: string } };
  403. }>({
  404. query: CUSTOMER_LOGOUT,
  405. // isCookies: true,
  406. revalidate: 3600,
  407. });
  408. const success = res?.body?.data?.createLogout?.logout?.success ?? false;
  409. const message =
  410. res?.body?.data?.createLogout?.logout?.message ?? "Logout executed";
  411. return {
  412. success,
  413. message,
  414. };
  415. } catch (error: unknown) {
  416. return {
  417. success: false,
  418. message: error instanceof Error ? error.message : "Something went wrong",
  419. };
  420. }
  421. }
  422. export async function recoverUserLogin(
  423. input: Record<string, unknown>,
  424. ): Promise<unknown> {
  425. try {
  426. return await bagistoFetch<{
  427. data: unknown;
  428. variables: Record<string, unknown>;
  429. }>({
  430. query: FORGET_PASSWORD,
  431. variables: {
  432. ...input,
  433. },
  434. cache: "no-store",
  435. revalidate: 3600,
  436. });
  437. } catch (error) {
  438. return error;
  439. }
  440. }
  441. export async function subscribeUser(
  442. input: Record<string, unknown>,
  443. ): Promise<unknown> {
  444. try {
  445. return await bagistoFetch<{
  446. data: unknown;
  447. variables: Record<string, unknown>;
  448. }>({
  449. query: SUBSCRIBE_TO_NEWSLETTER,
  450. variables: {
  451. ...input,
  452. },
  453. cache: "no-store",
  454. });
  455. } catch (error) {
  456. return error;
  457. }
  458. }
  459. export async function getThemeCustomization(): Promise<ThemeCustomizationResult> {
  460. try {
  461. const [{data: footerRes}, {data:servicesRes}] = await Promise.all([
  462. cachedGraphQLRequest<GetFooterResponse>("static", GET_FOOTER, {
  463. type: "footer_links",
  464. }),
  465. cachedGraphQLRequest<GetFooterResponse>("static", GET_FOOTER, {
  466. type: "services_content",
  467. }),
  468. ]);
  469. return {
  470. footer_links: footerRes,
  471. services_content: servicesRes,
  472. };
  473. } catch (err) {
  474. console.error("ThemeCustomization Error:", err);
  475. }
  476. return {
  477. footer_links: null,
  478. services_content: null,
  479. };
  480. }
  481. export async function revalidate(req: NextRequest): Promise<NextResponse> {
  482. const collectionWebhooks = [
  483. "collections/create",
  484. "collections/delete",
  485. "collections/update",
  486. ];
  487. const productWebhooks = [
  488. "products/create",
  489. "products/delete",
  490. "products/update",
  491. ];
  492. const topic = (await headers()).get("x-bagisto-topic") || "unknown";
  493. const secret = req.nextUrl.searchParams.get("secret");
  494. const isCollectionUpdate = collectionWebhooks.includes(topic);
  495. const isProductUpdate = productWebhooks.includes(topic);
  496. if (!secret || secret !== process.env.BAGISTO_REVALIDATION_SECRET) {
  497. return NextResponse.json({ status: 200 });
  498. }
  499. if (!isCollectionUpdate && !isProductUpdate) {
  500. return NextResponse.json({ status: 200, message: "No action needed" });
  501. }
  502. if (isProductUpdate) {
  503. revalidatePath("/", "layout");
  504. } else if (isCollectionUpdate) {
  505. revalidatePath("/", "layout");
  506. }
  507. return NextResponse.json({
  508. status: 200,
  509. revalidated: true,
  510. topic,
  511. now: Date.now(),
  512. });
  513. }
  514. export async function getHomePageData(): Promise<ThemeCustomizationResponse> {
  515. const res = await bagistoFetch<{
  516. data: ThemeCustomizationResponse;
  517. variables: { first: number };
  518. }>({
  519. query: GET_THEME_CUSTOMIZATION,
  520. variables: { first: 20 },
  521. tags: ["theme-customization"],
  522. revalidate: 60,
  523. });
  524. return res.body.data;
  525. }
  526. export async function getPage(input: { urlKey: string }): Promise<PageData[]> {
  527. const res = await bagistoFetch<{
  528. data: PageByUrlKeyResponse;
  529. variables: { pageByUrlKey: string };
  530. }>({
  531. query: PAGE_BY_URL_KEY,
  532. cache: "no-store",
  533. // isCookies: false,
  534. variables: { pageByUrlKey: input.urlKey },
  535. });
  536. return res.body.data?.pageByUrlKeypages || [];
  537. }