diff --git a/README.md b/README.md index 647bfd33..ba5e8d39 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ $ yarn create commerce [See the live demo](https://blazity.com/r/commerce) or deploy it straight to Vercel: -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FBlazity%2Fenterprise-commerce%2Ftree%2Fmain&envDescription=Full%20explanation%20on%20how%20to%20obtain%20keys&envLink=https%3A%2F%2Fdocs.commerce.blazity.com%2Fsetup&demo-title=Your%20Commerce&demo-description=AI-FIRST%20NEXT.JS%20STOREFRONT%20FOR%20COMPOSABLE%20COMMERCE&demo-url=https%3A%2F%2Fblazity.com%2Fr%2Fcommerce&demo-image=https%3A%2F%2Fcommerce.blazity.com%2Fopengraph-image.jpg&root-directory=apps%2Fweb) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fblazity%2Fenterprise-commerce%2Ftree%2Fmain%2Fstarters%2Fshopify-algolia) - Shopify & Algolia starter +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fblazity%2Fenterprise-commerce%2Ftree%2Fmain%2Fstarters%2Fshopify-meilisearch) - Shopify & Meilsearch starter **Note:** To enable all features, ensure [required environment variables](https://docs.commerce.blazity.com/setup#manual) are set in your `.env.local` @@ -50,9 +51,7 @@ $ yarn create commerce ## Architecture -In Enterprise Commerce high-level architecture, Meilisearch serves as the primary source for all product data and potentially other types of data in the future. The system is designed to easily integrate AI personalization tools without needing to modify any frontend code. While we are integrated with Shopify by default, we are not tightly bound to it, and you can use any system that works with Meilisearch and can adapt data to our format. - -From a structural viewpoint, we use a monorepo (Turborepo) to manage packages, even though we currently have only one Next.js app. We chose this setup because it prepares us for future developments, which will include additional apps. This arrangement helps keep the packages well-separated and self-contained. +In Enterprise Commerce high-level architecture, Search Engine serves as the primary source for all product data and potentially other types of data in the future. The system is designed to easily integrate AI personalization tools without needing to modify any frontend code. While we are integrated with Shopify by default, we are not tightly bound to it, you can use any commerce platform and adapt data to our format. architecture diagram diff --git a/starters/shopify-algolia/.eslintignore b/starters/shopify-algolia/.eslintignore new file mode 100644 index 00000000..8fc978f4 --- /dev/null +++ b/starters/shopify-algolia/.eslintignore @@ -0,0 +1,8 @@ +.next +node_modules +gql + +dist +/dist +dist/* +dist/**/* \ No newline at end of file diff --git a/starters/shopify-algolia/.eslintrc.js b/starters/shopify-algolia/.eslintrc.js new file mode 100644 index 00000000..44fd1243 --- /dev/null +++ b/starters/shopify-algolia/.eslintrc.js @@ -0,0 +1,39 @@ +/* eslint-env es6 */ +/* eslint-disable no-console */ + +module.exports = { + globals: { + React: true, + JSX: true, + }, + extends: ["next", "prettier", "react-app", "react-app/jest", "plugin:storybook/recommended", "plugin:tailwindcss/recommended"], + parserOptions: { + babelOptions: { + presets: [require.resolve("next/babel")], + }, + ecmaVersion: "latest", + }, + env: { + es6: true, + }, + rules: { + "tailwindcss/no-custom-classname": "off", + "testing-library/prefer-screen-queries": "off", + "@next/next/no-html-link-for-pages": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "sort-imports": [ + "error", + { + ignoreCase: true, + ignoreDeclarationSort: true, + }, + ], + "tailwindcss/classnames-order": "off", + }, +} diff --git a/starters/shopify-algolia/.gitignore b/starters/shopify-algolia/.gitignore new file mode 100644 index 00000000..fccde6d1 --- /dev/null +++ b/starters/shopify-algolia/.gitignore @@ -0,0 +1,22 @@ +.next/ +out/ +build + +build/** +dist/** +**/dist/** +.next/** + + +/.npm-only-allow + +storybook-static/ +playwright-report/ +playwright/.cache/ +test-results/ + +graph.svg + +# testing +coverage +.vercel diff --git a/starters/shopify-algolia/.graphqlrc.ts b/starters/shopify-algolia/.graphqlrc.ts new file mode 100644 index 00000000..38b7dc73 --- /dev/null +++ b/starters/shopify-algolia/.graphqlrc.ts @@ -0,0 +1,20 @@ +import { ApiType, shopifyApiProject } from "@shopify/api-codegen-preset" + +export default { + schema: ["https://shopify.dev/storefront-graphql-direct-proxy/2024-01", "https://shopify.dev/admin-graphql-direct-proxy/2024-01"], + documents: ["./**/*.{js,ts,jsx,tsx}"], + projects: { + default: shopifyApiProject({ + apiType: ApiType.Storefront, + apiVersion: "2024-01", + documents: ["./lib/shopify/**/*.storefront.{js,ts,jsx,tsx}", "./lib/shopify/**/fragments/*.{js,ts,jsx,tsx}"], + outputDir: "./lib/shopify/types", + }), + admin: shopifyApiProject({ + apiType: ApiType.Admin, + apiVersion: "2024-01", + documents: ["./lib/shopify/**/*.admin.{js,ts,jsx,tsx}"], + outputDir: "./lib/shopify/types/admin", + }), + }, +} diff --git a/starters/shopify-algolia/.prettierignore b/starters/shopify-algolia/.prettierignore new file mode 100644 index 00000000..8981f58a --- /dev/null +++ b/starters/shopify-algolia/.prettierignore @@ -0,0 +1,3 @@ +.next +node_modules +gql \ No newline at end of file diff --git a/starters/shopify-algolia/.storybook/main.ts b/starters/shopify-algolia/.storybook/main.ts new file mode 100644 index 00000000..136f062f --- /dev/null +++ b/starters/shopify-algolia/.storybook/main.ts @@ -0,0 +1,30 @@ +import { dirname, join } from "path" +import type { StorybookConfig } from "@storybook/nextjs" +const config: StorybookConfig = { + stories: ["../components/**/*.mdx", "../components/**/*.stories.@(js|jsx|ts|tsx)"], + addons: [getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@storybook/addon-interactions")], + framework: { + name: getAbsolutePath("@storybook/nextjs"), + options: {}, + }, + features: { + experimentalRSC: true, + }, + docs: { + autodocs: "tag", + }, + typescript: { + check: false, + checkOptions: {}, + reactDocgen: "react-docgen-typescript", + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true), + }, + }, +} +export default config + +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))) +} diff --git a/starters/shopify-algolia/.storybook/preview.ts b/starters/shopify-algolia/.storybook/preview.ts new file mode 100644 index 00000000..266989d5 --- /dev/null +++ b/starters/shopify-algolia/.storybook/preview.ts @@ -0,0 +1,17 @@ +import type { Preview } from "@storybook/react" + +import "../app/globals.css" + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +} + +export default preview diff --git a/starters/shopify-algolia/app/.well-known/vercel/flags/route.ts b/starters/shopify-algolia/app/.well-known/vercel/flags/route.ts new file mode 100644 index 00000000..30903d4f --- /dev/null +++ b/starters/shopify-algolia/app/.well-known/vercel/flags/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server" +import { verifyAccess, type ApiData } from "@vercel/flags" + +export async function GET(request: NextRequest) { + const access = await verifyAccess(request.headers.get("Authorization")) + if (!access) return NextResponse.json(null, { status: 401 }) + + const apiData = { + definitions: { + isVercelAnalyticsEnabled: { + description: "Controls whether the new feature is visible", + options: [ + { value: false, label: "Off" }, + { value: true, label: "On" }, + ], + }, + isGoogleTagManagerEnabled: { + description: "Controls whether the new feature is visible", + options: [ + { value: false, label: "Off" }, + { value: true, label: "On" }, + ], + }, + isSpeedInsightsEnabled: { + description: "Controls whether the new feature is visible", + options: [ + { value: false, label: "Off" }, + { value: true, label: "On" }, + ], + }, + }, + } as ApiData + + return NextResponse.json(apiData) +} diff --git a/starters/shopify-algolia/app/access-denied/page.tsx b/starters/shopify-algolia/app/access-denied/page.tsx new file mode 100644 index 00000000..adb01d73 --- /dev/null +++ b/starters/shopify-algolia/app/access-denied/page.tsx @@ -0,0 +1,13 @@ +import Link from "next/link" + +export default function AccessDenied() { + return ( +
+

Looks like you don't have access to this page. If you were logged in before your session might've expired. Please log in again! 😊

+ + + Go Home + +
+ ) +} diff --git a/starters/shopify-algolia/app/actions/cart.actions.ts b/starters/shopify-algolia/app/actions/cart.actions.ts new file mode 100644 index 00000000..4459ef29 --- /dev/null +++ b/starters/shopify-algolia/app/actions/cart.actions.ts @@ -0,0 +1,86 @@ +"use server" + +import { revalidateTag, unstable_cache } from "next/cache" +import { cookies } from "next/headers" +import { storefrontClient } from "clients/storefrontClient" +import { COOKIE_CART_ID, TAGS } from "constants/index" +import { isDemoMode } from "utils/demoUtils" + +export const getCart = unstable_cache(async (cartId: string) => storefrontClient.getCart(cartId), [TAGS.CART], { revalidate: 60 * 15, tags: [TAGS.CART] }) + +export async function addCartItem(prevState: any, variantId: string) { + if (isDemoMode()) return { ok: false, message: "Demo mode active. Filtering, searching, and adding to cart disabled." } + if (!variantId) return { ok: false } + + let cartId = cookies().get(COOKIE_CART_ID)?.value + let cart + + if (cartId) cart = await storefrontClient.getCart(cartId) + + if (!cartId || !cart) { + cart = await storefrontClient.createCart([]) + cartId = cart?.id + cartId && cookies().set(COOKIE_CART_ID, cartId) + + revalidateTag(TAGS.CART) + } + + const itemAvailability = await getItemAvailability(cartId, variantId) + + if (!itemAvailability || itemAvailability.inCartQuantity >= itemAvailability.inStockQuantity) + return { + ok: false, + message: "This product is out of stock", + } + + await storefrontClient.createCartItem(cartId!, [{ merchandiseId: variantId, quantity: 1 }]) + revalidateTag(TAGS.CART) + + return { ok: true } +} + +export async function getItemAvailability(cartId: string | null | undefined, variantId: string | null | undefined) { + if (!cartId || !variantId) return { inCartQuantity: 0, inStockQuantity: Infinity } + + const cart = await storefrontClient.getCart(cartId) + const cartItem = cart?.items?.find((item) => item.merchandise.id === variantId) + + return { inCartQuantity: cartItem?.quantity ?? 0, inStockQuantity: cartItem?.merchandise.quantityAvailable ?? Infinity } +} + +export async function removeCartItem(prevState: any, itemId: string) { + const cartId = cookies().get(COOKIE_CART_ID)?.value + + if (!cartId) return { ok: false } + + await storefrontClient.deleteCartItem(cartId!, [itemId]) + revalidateTag(TAGS.CART) + + return { ok: true } +} + +export async function updateItemQuantity(prevState: any, payload: { itemId: string; variantId: string; quantity: number }) { + const cartId = cookies().get(COOKIE_CART_ID)?.value + + if (!cartId) return { ok: false } + + const { itemId, variantId, quantity } = payload + + if (quantity === 0) { + await storefrontClient.deleteCartItem(cartId, [itemId]) + revalidateTag(TAGS.CART) + return { ok: true } + } + + const itemAvailability = await getItemAvailability(cartId, variantId) + if (!itemAvailability || quantity > itemAvailability.inStockQuantity) + return { + ok: false, + message: "This product is out of stock", + } + + await storefrontClient.updateCartItem(cartId, [{ id: itemId, merchandiseId: variantId, quantity }]) + + revalidateTag(TAGS.CART) + return { ok: true } +} diff --git a/starters/shopify-algolia/app/actions/collection.actions.ts b/starters/shopify-algolia/app/actions/collection.actions.ts new file mode 100644 index 00000000..50a6c5a9 --- /dev/null +++ b/starters/shopify-algolia/app/actions/collection.actions.ts @@ -0,0 +1,26 @@ +"use server" + +import { unstable_cache } from "next/cache" +import { algolia } from "clients/search" +import { getDemoSingleCategory, isDemoMode } from "utils/demoUtils" +import type { PlatformCollection } from "lib/shopify/types" +import { env } from "env.mjs" + +export const getCollection = unstable_cache( + async (slug: string) => { + if (isDemoMode()) return getDemoSingleCategory(slug) + + const results = await algolia.search({ + indexName: env.ALGOLIA_CATEGORIES_INDEX, + searchParams: { + filters: algolia.filterBuilder().where("handle", slug).build(), + hitsPerPage: 1, + attributesToRetrieve: ["handle", "title", "seo"], + }, + }) + + return results.hits.find(Boolean) || null + }, + ["category-by-handle"], + { revalidate: 3600 } +) diff --git a/starters/shopify-algolia/app/actions/favorites.actions.ts b/starters/shopify-algolia/app/actions/favorites.actions.ts new file mode 100644 index 00000000..b4ec63d1 --- /dev/null +++ b/starters/shopify-algolia/app/actions/favorites.actions.ts @@ -0,0 +1,20 @@ +"use server" + +import { COOKIE_FAVORITES } from "constants/index" +import { cookies } from "next/headers" + +export async function toggleFavoriteProduct(prevState: any, handle: string) { + const handles = await getParsedFavoritesHandles() + const isFavorite = handles.includes(handle) + const newFavorites = handles.includes(handle) ? handles.filter((i) => i !== handle) : [...handles, handle] + + cookies().set(COOKIE_FAVORITES, JSON.stringify(newFavorites)) + + return !isFavorite +} + +export async function getParsedFavoritesHandles() { + const favoritesCookie = cookies().get(COOKIE_FAVORITES)?.value || "[]" + const favoritesHandles = JSON.parse(favoritesCookie) as string[] + return favoritesHandles +} diff --git a/starters/shopify-algolia/app/actions/page.actions.ts b/starters/shopify-algolia/app/actions/page.actions.ts new file mode 100644 index 00000000..74d98775 --- /dev/null +++ b/starters/shopify-algolia/app/actions/page.actions.ts @@ -0,0 +1,8 @@ +"use server" + +import { storefrontClient } from "clients/storefrontClient" +import { unstable_cache } from "next/cache" + +export const getPage = unstable_cache(async (handle: string) => await storefrontClient.getPage(handle), ["page"], { revalidate: 3600 }) + +export const getAllPages = unstable_cache(async () => await storefrontClient.getAllPages(), ["page"], { revalidate: 3600 }) diff --git a/starters/shopify-algolia/app/actions/product.actions.ts b/starters/shopify-algolia/app/actions/product.actions.ts new file mode 100644 index 00000000..42bffde1 --- /dev/null +++ b/starters/shopify-algolia/app/actions/product.actions.ts @@ -0,0 +1,76 @@ +"use server" + +import { unstable_cache } from "next/cache" +import { env } from "env.mjs" + +import { algolia } from "clients/search" +import type { Review } from "lib/reviews/types" + +import { getDemoProductReviews, getDemoSingleProduct, isDemoMode } from "utils/demoUtils" +import type { CommerceProduct } from "types" +import { notifyOptIn } from "utils/opt-in" + +export const searchProducts = unstable_cache( + async (query: string, limit: number = 4) => { + if (isDemoMode()) + return { + hits: [], + hasMore: false, + } + + const { hits, estimatedTotalHits } = await algolia.search({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + searchParams: { + query, + hitsPerPage: limit, + attributesToRetrieve: ["id", "handle", "title", "featuredImage", "images", "variants"], + }, + }) + return { hits, hasMore: estimatedTotalHits > limit } + }, + ["autocomplete-search"], + { revalidate: 3600 } +) + +export const getProduct = unstable_cache( + async (handle: string) => { + if (isDemoMode()) return getDemoSingleProduct(handle) + + const { hits } = await algolia.search({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + searchParams: { + filters: algolia.filterBuilder().where("handle", handle).build(), + hitsPerPage: 1, + }, + }) + + return hits.find(Boolean) || null + }, + ["product-by-handle"], + { revalidate: 3600 } +) + +export const getProductReviews = unstable_cache( + async (handle: string, { page = 1, limit = 10 } = { page: 1, limit: 10 }) => { + if (isDemoMode()) return getDemoProductReviews() + + if (!env.ALGOLIA_REVIEWS_INDEX) { + notifyOptIn({ feature: "reviews", source: "product.actions.ts" }) + return { reviews: [], total: 0 } + } + + const { hits, nbHits } = await algolia.search({ + indexName: env.ALGOLIA_REVIEWS_INDEX, + searchParams: { + filters: algolia.filterBuilder().where("product_handle", handle).and().where("published", "true").and().where("hidden", "false").build(), + hitsPerPage: limit, + page, + attributesToRetrieve: ["body", "rating", "verified", "reviewer", "published", "created_at", "hidden", "featured"], + }, + }) + + return { reviews: hits.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()), total: nbHits } + }, + ["product-reviews-by-handle"], + { revalidate: 3600 } +) diff --git a/starters/shopify-algolia/app/actions/reviews.actions.ts b/starters/shopify-algolia/app/actions/reviews.actions.ts new file mode 100644 index 00000000..ac9c03e3 --- /dev/null +++ b/starters/shopify-algolia/app/actions/reviews.actions.ts @@ -0,0 +1,15 @@ +"use server" + +import { reviewsClient } from "clients/reviews" +import type { ProductReviewBody } from "lib/reviews/types" + +import { headers } from "next/headers" + +export const submitReview = async (payload: Omit) => { + try { + const ipAddress = headers().get("x-forwarded-for") || null + await reviewsClient.createProductReview({ ...payload, ip_addr: ipAddress }) + } catch (err) { + throw new Error(err as string) + } +} diff --git a/starters/shopify-algolia/app/actions/user.actions.ts b/starters/shopify-algolia/app/actions/user.actions.ts new file mode 100644 index 00000000..f79d8f5f --- /dev/null +++ b/starters/shopify-algolia/app/actions/user.actions.ts @@ -0,0 +1,34 @@ +"use server" + +import { PlatformUserCreateInput } from "lib/shopify/types" +import { cookies } from "next/headers" +import { storefrontClient } from "clients/storefrontClient" +import { COOKIE_ACCESS_TOKEN } from "constants/index" + +export async function signupUser({ email, password }: { email: string; password: string }) { + const user = await storefrontClient.createUser({ email, password }) + return user +} + +export async function loginUser({ email, password }: { email: string; password: string }) { + const user = await storefrontClient.createUserAccessToken({ email, password }) + cookies().set(COOKIE_ACCESS_TOKEN, user?.accessToken || "", { expires: new Date(user?.expiresAt || "") }) + return user +} + +export async function getCurrentUser() { + const accessToken = cookies().get(COOKIE_ACCESS_TOKEN)?.value + const user = await storefrontClient.getUser(accessToken || "") + return user +} + +export async function updateUser(input: Pick) { + const accessToken = cookies().get(COOKIE_ACCESS_TOKEN)?.value + + const user = await storefrontClient.updateUser(accessToken!, { ...input }) + return user +} + +export async function logoutUser() { + cookies().delete(COOKIE_ACCESS_TOKEN) +} diff --git a/starters/shopify-algolia/app/api/feed/sync/route.ts b/starters/shopify-algolia/app/api/feed/sync/route.ts new file mode 100644 index 00000000..0086cb10 --- /dev/null +++ b/starters/shopify-algolia/app/api/feed/sync/route.ts @@ -0,0 +1,121 @@ +import type { PlatformProduct } from "lib/shopify/types" +import { algolia } from "clients/search" +import { storefrontClient } from "clients/storefrontClient" +import { env } from "env.mjs" +import { compareHmac } from "utils/compare-hmac" +import { enrichProduct } from "utils/enrich-product" + +type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create" + +/* + * Callback Endpoint for Shopify Webhook product updates + */ +export async function POST(req: Request) { + const hmac = req.headers.get("X-Shopify-Hmac-Sha256") + const topic = req.headers.get("X-Shopify-Topic") + const secret = env.SHOPIFY_APP_API_SECRET_KEY + const rawPayload = await req.text() + + if (!secret || !hmac || !topic) { + return new Response(JSON.stringify({ message: "Not all credentials were provided for the deployment" }), { status: 500, headers: { "Content-Type": "application/json" } }) + } + + if ( + !compareHmac({ + body: rawPayload, + hmac, + secret, + }) + ) { + return new Response(JSON.stringify({ message: "Could not verify request." }), { status: 401, headers: { "Content-Type": "application/json" } }) + } + // there is no clear docs for what the payload looks like for different topics + const { id } = JSON.parse(rawPayload) as Record + + if (!id) { + return new Response(JSON.stringify({ message: "Invalid payload" }), { status: 400, headers: { "Content-Type": "application/json" } }) + } + + if (topic.startsWith("products")) { + return await handleProductTopics(topic as SupportedTopic, { id }) + } else if (topic.startsWith("collections")) { + return await handleCollectionTopics(topic as SupportedTopic, { id }) + } else { + return new Response(JSON.stringify({ message: "Unsupported topic" }), { status: 400, headers: { "Content-Type": "application/json" } }) + } +} + +async function handleCollectionTopics(topic: SupportedTopic, { id }: Record): Promise { + switch (topic) { + case "collections/update": + case "collections/create": + const collection = await storefrontClient.getCollectionById(makeShopifyId(`${id}`, "Collection")) + if (!collection) { + console.error(`Collection ${id} not found`) + return new Response(JSON.stringify({ message: "Collection not found" }), { status: 404, headers: { "Content-Type": "application/json" } }) + } + await algolia.update({ + indexName: env.ALGOLIA_CATEGORIES_INDEX, + objects: [{ ...collection, id: `${id}` }], + }) + + break + + case "collections/delete": + await algolia.delete({ + indexName: env.ALGOLIA_CATEGORIES_INDEX, + objectIDs: [id], + }) + break + + default: + return new Response(JSON.stringify({ message: "Unsupported topic" }), { status: 400, headers: { "Content-Type": "application/json" } }) + } + + return new Response(JSON.stringify({ message: "Success" }), { status: 200, headers: { "Content-Type": "application/json" } }) +} + +async function handleProductTopics(topic: SupportedTopic, { id }: Record): Promise { + switch (topic) { + case "products/update": + case "products/create": + const product = await storefrontClient.getProduct(makeShopifyId(`${id}`, "Product")) + const items = env.SHOPIFY_HIERARCHICAL_NAV_HANDLE ? (await storefrontClient.getHierarchicalCollections(env.SHOPIFY_HIERARCHICAL_NAV_HANDLE)).items : [] + + if (!product) { + console.error(`Product ${id} not found`) + return new Response(JSON.stringify({ message: "Product not found" }), { status: 404, headers: { "Content-Type": "application/json" } }) + } + + const enrichedProduct = await enrichProduct(product, items) + await algolia.update({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + objects: [normalizeProduct(enrichedProduct, id)], + }) + + break + case "products/delete": + await algolia.delete({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + objectIDs: [id], + }) + break + + default: + return new Response(JSON.stringify({ message: "Unsupported topic" }), { status: 400, headers: { "Content-Type": "application/json" } }) + } + + return new Response(JSON.stringify({ message: "Success" }), { status: 200, headers: { "Content-Type": "application/json" } }) +} + +/* Extract into utils */ +function normalizeProduct(product: PlatformProduct, originalId: string): PlatformProduct { + return { + ...product, + id: originalId, + } +} + +function makeShopifyId(id: string, type: "Product" | "Collection") { + return id.startsWith("gid://shopify/") ? id : `gid://shopify/${type}/${id}` +} diff --git a/starters/shopify-algolia/app/api/health/route.ts b/starters/shopify-algolia/app/api/health/route.ts new file mode 100644 index 00000000..e8fd8c53 --- /dev/null +++ b/starters/shopify-algolia/app/api/health/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ status: "ok" }) +} diff --git a/starters/shopify-algolia/app/api/redirects/route.ts b/starters/shopify-algolia/app/api/redirects/route.ts new file mode 100644 index 00000000..7b1fd799 --- /dev/null +++ b/starters/shopify-algolia/app/api/redirects/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server" +import redirects from "../../../redirects/new-redirects.json" + +type RedirectEntry = { + destination: string + permanent: boolean +} + +export const runtime = "edge" + +export function GET(request: NextRequest) { + const pathname = request.nextUrl.searchParams.get("pathname") + + if (!pathname) { + return new Response("Bad Request", { status: 400 }) + } + + const redirect = (redirects as Record)[pathname] + + if (!redirect) { + return new Response("No redirect", { status: 400 }) + } + + return NextResponse.json(redirect) +} diff --git a/starters/shopify-algolia/app/api/reviews/ai-summary/route.ts b/starters/shopify-algolia/app/api/reviews/ai-summary/route.ts new file mode 100644 index 00000000..7fc36d89 --- /dev/null +++ b/starters/shopify-algolia/app/api/reviews/ai-summary/route.ts @@ -0,0 +1,173 @@ +import { generateObject } from "ai" +import z from "zod" +import { openai } from "@ai-sdk/openai" +import type { Review } from "lib/reviews/types" +import type { CommerceProduct } from "types" +import { algolia } from "clients/search" +import { env } from "env.mjs" +import { authenticate } from "utils/authenticate-api-route" +import { isOptIn, notifyOptIn } from "utils/opt-in" +import { unstable_noStore } from "next/cache" +import { isDemoMode } from "utils/demoUtils" + +const summarySchema = z.object({ + products: z.array( + z.object({ + handle: z.string(), + id: z.string(), + reviewsSummary: z.string(), + }) + ), +}) + +export const maxDuration = 60 + +/* + * This API route will be used for cron job, running once a week to re-generate AI summary based on all user reviews, tweak to your needs + */ +export async function GET(req: Request) { + unstable_noStore() + if (!authenticate(req)) { + return new Response("Unauthorized", { + status: 401, + }) + } + + if (!isOptIn("ai-reviews")) { + const res = notifyOptIn({ feature: "ai-reviews", source: "api/reviews/ai-summary" }) + return new Response(JSON.stringify(res), { status: 200 }) + } + + if (isDemoMode() || !env.ALGOLIA_REVIEWS_INDEX) { + console.error({ + message: "Lacking environment variables", + source: "api/reviews/ai-summary", + }) + return new Response(JSON.stringify({ message: "Sorry, something went wrong" }), { status: 500 }) + } + + const [{ hits: allReviews = [] }, { hits: allProducts = [] }] = await Promise.all([ + algolia.getAllResults({ + indexName: env.ALGOLIA_REVIEWS_INDEX, + browseParams: { + attributesToRetrieve: ["body", "title", "product_handle", "rating"], + filters: "published:true AND hidden:false", + }, + }), + algolia.getAllResults({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + browseParams: { + attributesToRetrieve: ["handle", "title", "id", "totalReviews"], + }, + }), + ]) + + const mappedReviews: Record = allReviews.reduce((acc, review) => { + const productHandle = review.product_handle + if (acc[productHandle]) { + acc[productHandle].push(review) + } else { + acc[productHandle] = [review] + } + + return acc + }, {}) + + const productsWithNewReviews = allProducts.filter((product) => product.totalReviews !== (mappedReviews[product.handle]?.length || 0)) + + if (!productsWithNewReviews.length) { + return new Response(JSON.stringify({ message: "No new reviews to re-generate summary" }), { status: 200 }) + } + + const productsWithReviews = productsWithNewReviews + .map((product) => { + if (!mappedReviews[product.handle]) { + return null + } + return { + product_title: product.title, + id: product.id, + handle: product.handle, + reviews: mappedReviews[product.handle], + } + }) + .filter(Boolean) + + const batches: Batch[] = [] + const results: { handle: string; id: string; reviewsSummary: string }[] = [] + const batchSize = 25 + + for (let i = 0; i < productsWithReviews.length; i += batchSize) { + const batch = productsWithReviews.slice(i, i + batchSize) + + batches.push(batch) + } + + for (const batch of batches) { + const products = await generateBatchSummaries(batch) + results.push(...products) + } + + const updatedProducts = results + .map((result) => { + const id = productsWithReviews?.find(({ id }) => id === result.id)?.id + + if (!id) return null + + return { + id, + reviewsSummary: result.reviewsSummary, + } + }) + .filter(Boolean) + + await algolia.update({ indexName: env.ALGOLIA_PRODUCTS_INDEX, objects: updatedProducts }) + + return new Response(JSON.stringify({ message: "Reviews synced" }), { status: 200 }) +} + +type Batch = { + handle: string + id: string + reviews: Review[] +}[] + +const instructions = ` + You will be given a list of products and a list reviews for each of them. Your task is to generate a short summary (maximum up to 4 sentences) for each product based on the reviews provided highlighting best features and one or two areas of improvement if any are mentioned if not just don't mention it at all. Avoid repeating the same information in the summary whilst keeping casual tone. + + Example of its structure: + { + "products": [{ + "product_title": "Sleek Watch", + "id": "1", + "handle": "sleek-watch", + "reviews": [ + { + "title": "Great watch", + "body": "I love this watch, it's sleek and stylish. The only downside is that the battery life is not as long as I would like.", + "rating": 5 + }, + { + "title": "Very stylish", + "body": "I love the design of this watch, it's very stylish and goes with everything. The only downside is that the strap is a bit uncomfortable.", + "rating": 4 + }, + + ] + }] + } + + Here's the list of products and reviews: + ` + +async function generateBatchSummaries(batch: Batch) { + const { object } = await generateObject({ + model: openai("gpt-4o"), + system: instructions, + prompt: JSON.stringify(batch), + schema: summarySchema, + mode: "json", + }) + + return object.products +} diff --git a/starters/shopify-algolia/app/api/reviews/sync/route.ts b/starters/shopify-algolia/app/api/reviews/sync/route.ts new file mode 100644 index 00000000..88085b5f --- /dev/null +++ b/starters/shopify-algolia/app/api/reviews/sync/route.ts @@ -0,0 +1,93 @@ +import { unstable_noStore } from "next/cache" +import { algolia } from "clients/search" +import { reviewsClient } from "clients/reviews" +import { env } from "env.mjs" +import { authenticate } from "utils/authenticate-api-route" +import { isOptIn, notifyOptIn } from "utils/opt-in" +import type { Review } from "lib/reviews/types" +import type { CommerceProduct } from "types" +import { isDemoMode } from "utils/demoUtils" + +export const maxDuration = 60 + +export async function GET(req: Request) { + unstable_noStore() + if (!authenticate(req)) { + return new Response("Unauthorized", { + status: 401, + }) + } + + if (!isOptIn("reviews")) { + const res = notifyOptIn({ feature: "reviews", source: "api/reviews/sync" }) + return new Response(JSON.stringify(res), { status: 200 }) + } + + if (isDemoMode() || !env.ALGOLIA_REVIEWS_INDEX) { + console.error({ + message: "Lacking environment variables", + source: "api/reviews/sync", + }) + return new Response(JSON.stringify({ message: "Sorry, something went wrong" }), { status: 500 }) + } + + const [allReviews = [], { hits: allProducts = [] }, { hits: allIndexReviews = [] }] = await Promise.all([ + reviewsClient.getAllProductReviews(), + algolia.getAllResults({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + browseParams: { + attributesToRetrieve: ["handle", "totalReviews", "avgRating", "id"], + }, + }), + algolia.getAllResults({ + indexName: env.ALGOLIA_REVIEWS_INDEX, + browseParams: { + attributesToRetrieve: ["updated_at", "id"], + }, + }), + ]) + + const reviewsDelta = allReviews.filter((review) => { + const indexReview = allIndexReviews.find((r) => r.id === review.id) + return indexReview?.updated_at !== review.updated_at + }) + + const productTotalReviewsDelta = allProducts + .map((product) => { + const productReviews = allReviews.filter((review) => review.product_handle === product.handle && review.published && !review.hidden) + if (!!productReviews.length && productReviews.length !== product.totalReviews) { + const avgRating = productReviews.reduce((acc, review) => acc + review.rating, 0) / productReviews.length || 0 + return { ...product, avgRating, totalReviews: productReviews.length } + } + + return null + }) + .filter(Boolean) + + if (!reviewsDelta.length && !productTotalReviewsDelta.length) { + return new Response(JSON.stringify({ message: "Nothing to sync" }), { + status: 200, + }) + } + + !!reviewsDelta.length && + (async () => { + algolia.update({ + indexName: env.ALGOLIA_REVIEWS_INDEX!, + objects: reviewsDelta, + }) + console.log("API/sync: Reviews synced", reviewsDelta.length) + })() + !!productTotalReviewsDelta.length && + (async () => { + algolia.update({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + objects: productTotalReviewsDelta, + }) + console.log("API/sync:Products synced", productTotalReviewsDelta.length) + })() + + return new Response(JSON.stringify({ message: "All synced" }), { + status: 200, + }) +} diff --git a/starters/shopify-algolia/app/category/clp/[slug]/[page]/page.tsx b/starters/shopify-algolia/app/category/clp/[slug]/[page]/page.tsx new file mode 100644 index 00000000..ba9b0dfb --- /dev/null +++ b/starters/shopify-algolia/app/category/clp/[slug]/[page]/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next" +import { CategoryView } from "views/Category/CategoryView" + +export const revalidate = 86400 +export const dynamic = "force-static" + +interface CategoryPageProps { + params: { slug: string; page: string } +} + +export async function generateMetadata({ params }: CategoryPageProps): Promise { + return { + title: `${params.slug} | Enterprise Commerce`, + description: "In excepteur elit mollit in.", + } +} + +export async function generateStaticParams() { + return [] +} + +export default async function CategoryPage({ params }: CategoryPageProps) { + return +} diff --git a/starters/shopify-algolia/app/category/clp/[slug]/page.tsx b/starters/shopify-algolia/app/category/clp/[slug]/page.tsx new file mode 100644 index 00000000..b2961f13 --- /dev/null +++ b/starters/shopify-algolia/app/category/clp/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { PlatformCollection } from "lib/shopify/types" +import { algolia } from "clients/search" +import { env } from "env.mjs" +import type { Metadata } from "next" +import { isDemoMode } from "utils/demoUtils" +import { CategoryView } from "views/Category/CategoryView" + +export const revalidate = 86400 +export const dynamic = "force-static" + +interface CategoryPageProps { + params: { slug: string } +} + +export async function generateMetadata({ params }: CategoryPageProps): Promise { + return { + title: `${params.slug} | Enterprise Commerce`, + description: "In excepteur elit mollit in.", + } +} + +export async function generateStaticParams() { + if (isDemoMode()) return [] + + const { hits } = await algolia.search({ + indexName: env.ALGOLIA_CATEGORIES_INDEX, + searchParams: { + hitsPerPage: 50, + attributesToRetrieve: ["handle"], + }, + }) + + return hits.map(({ handle }) => ({ slug: handle })) +} + +export default async function CategoryPage({ params }: CategoryPageProps) { + return +} diff --git a/starters/shopify-algolia/app/category/plp/[slug]/page.tsx b/starters/shopify-algolia/app/category/plp/[slug]/page.tsx new file mode 100644 index 00000000..a1ca05ef --- /dev/null +++ b/starters/shopify-algolia/app/category/plp/[slug]/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next" +import { SearchParamsType } from "types" +import { CategoryView } from "views/Category/CategoryView" + +export const runtime = "nodejs" + +export const revalidate = 86400 + +interface ProductListingPageProps { + searchParams: SearchParamsType + params: { slug: string } +} + +export async function generateMetadata({ params }: ProductListingPageProps): Promise { + return { + title: `${params.slug} | Enterprise Commerce`, + description: "In excepteur elit mollit in.", + } +} + +export default async function ProductListingPage({ searchParams, params }: ProductListingPageProps) { + return +} diff --git a/starters/shopify-algolia/app/error.tsx b/starters/shopify-algolia/app/error.tsx new file mode 100644 index 00000000..454e8dcf --- /dev/null +++ b/starters/shopify-algolia/app/error.tsx @@ -0,0 +1,33 @@ +"use client" + +import { Button } from "components/Button/Button" +import { isDemoMode } from "utils/demoUtils" + +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+
+ {isDemoMode() ? ( + <> +

Something went wrong!

+

+ This feature requires full functionality. Please exit Demo Mode by setting the required environment variables. +

+
{JSON.stringify(error, null, 2)}
+ + + ) : ( + <> +

Something went wrong!

+
{JSON.stringify(error, null, 2)}
+ + + )} +
+
+ ) +} diff --git a/starters/shopify-algolia/app/favorites/page.tsx b/starters/shopify-algolia/app/favorites/page.tsx new file mode 100644 index 00000000..432e1bce --- /dev/null +++ b/starters/shopify-algolia/app/favorites/page.tsx @@ -0,0 +1,61 @@ +import { cookies } from "next/headers" +import { Suspense } from "react" +import { getProduct } from "app/actions/product.actions" +import { ProductCard } from "components/ProductCard/ProductCard" +import { Skeleton } from "components/Skeleton/Skeleton" +import { COOKIE_FAVORITES } from "constants/index" + +export const revalidate = 86400 + +export const dynamicParams = true + +export default async function Favorites() { + return ( +
+
+

Favorite products

+
+ }> + + +
+ ) +} + +async function FavoritesView() { + let favoritesHandles: string[] = [] + const favoritesCookie = cookies().get(COOKIE_FAVORITES)?.value + + if (favoritesCookie) { + favoritesHandles = JSON.parse(favoritesCookie) as string[] + } + + const products = await Promise.all(favoritesHandles.map((handle) => getProduct(handle)).filter(Boolean)) + + return ( + <> + {products.length === 0 ?

No favorite products. You can add them by clicking on a heart icon on product page

: null} +
+ {products.map((singleResult, idx) => ( + + ))} +
+ + ) +} + +function FavoritesSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} diff --git a/starters/shopify-algolia/app/global-error.tsx b/starters/shopify-algolia/app/global-error.tsx new file mode 100644 index 00000000..754630ea --- /dev/null +++ b/starters/shopify-algolia/app/global-error.tsx @@ -0,0 +1,19 @@ +"use client" + +import { Button } from "components/Button/Button" + +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( + + +
+

Something went wrong!

+
{JSON.stringify(error, null, 2)}
+ +
+ + + ) +} diff --git a/starters/shopify-algolia/app/globals.css b/starters/shopify-algolia/app/globals.css new file mode 100644 index 00000000..f8c4a02a --- /dev/null +++ b/starters/shopify-algolia/app/globals.css @@ -0,0 +1,22 @@ +@import "./styles/reset.css"; +@import "./styles/megamenu.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} diff --git a/starters/shopify-algolia/app/home/[bucket]/page.tsx b/starters/shopify-algolia/app/home/[bucket]/page.tsx new file mode 100644 index 00000000..9e78392c --- /dev/null +++ b/starters/shopify-algolia/app/home/[bucket]/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from "react" +import { BUCKETS } from "constants/index" +import { BestOffersSection } from "views/Homepage/BestOffersSection" +import { CarouselSectionSkeleton } from "views/Homepage/CarouselSection" +import { CategoriesSection, CategoriesSectionSkeleton } from "views/Homepage/CategoriesSection" +import { EverythingUnderSection } from "views/Homepage/EverythingUnderSection" +import { AnnouncementBar } from "components/AnnouncementBar/AnnouncementBar" +import { HeroSection } from "views/Homepage/HeroSection" + +export const revalidate = 86400 + +export const dynamic = "force-static" + +export const dynamicParams = true + +export default function Homepage({ params: { bucket } }: { params: { bucket: string } }) { + const heroTitles = { + a: "Your daily trendsetting deals", + b: "Spring into Savings! Up to 60% Off", + } + + return ( +
+ + + + }> + + + + }> + + + + }> + + +
+ ) +} + +export async function generateStaticParams() { + return BUCKETS.HOME.map((bucket) => ({ bucket })) +} diff --git a/starters/shopify-algolia/app/icon.png b/starters/shopify-algolia/app/icon.png new file mode 100644 index 00000000..3fab1350 Binary files /dev/null and b/starters/shopify-algolia/app/icon.png differ diff --git a/starters/shopify-algolia/app/layout.tsx b/starters/shopify-algolia/app/layout.tsx new file mode 100644 index 00000000..233d5c0a --- /dev/null +++ b/starters/shopify-algolia/app/layout.tsx @@ -0,0 +1,271 @@ +import "./globals.css" + +import nextDynamic from "next/dynamic" +import Script from "next/script" +import { Suspense } from "react" +import { Toaster } from "sonner" +import { CallToAction } from "components/CallToAction/CallToAction" +import { Footer } from "components/Footer/Footer" +import { Modals } from "components/Modals/Modals" +import { mobileInlineScript } from "components/NavigationBar/mobileInlineScript" +import { NavigationBar } from "components/NavigationBar/NavigationBar" +import { NavItem } from "components/NavigationBar/types" +import { FlagValues } from "views/FlagValues" +import { ThirdParties } from "views/ThirdParties" +import { env } from "env.mjs" +import { Metadata } from "next" +import { GithubBadge } from "views/GithubBadge" +import { DemoModeAlert } from "views/DemoModeAlert" +import { CartView } from "views/Cart/CartView" + +const DraftToolbar = nextDynamic(() => import("views/DraftToolbar"), { ssr: false }) + +export const revalidate = 86400 + +const navigationItems: NavItem[] = [ + { + text: "Fashion", + href: "/category/fashion", + submenu: { + variant: "text-grid", + items: [ + { + text: "Women", + href: "/category/women", + items: [ + { text: "Shirts & Blouses", href: "/category/shirts-and-blouses" }, + { text: "Blazers & Vests", href: "/category/blazers-and-vests" }, + { text: "Cardigans & Sweaters", href: "/category/cardigans-and-sweaters" }, + { text: "Dresses", href: "/category/dresses" }, + { text: "Skirts", href: "/category/skirts" }, + ], + }, + { + text: "Men", + href: "/category/men", + items: [ + { text: "T-shirts & Tanks", href: "/category/t-shirts-and-tanks" }, + { text: "Hoodies & Sweatshirts", href: "/category/hoodies-and-sweatshirts" }, + { text: "Blazers & Suits", href: "/category/blazers-and-suits" }, + { text: "Shorts", href: "/category/shorts" }, + { text: "Outerwear", href: "/category/outerwear" }, + ], + }, + { + text: "Kids", + href: "/category/kids", + items: [ + { text: "Clothing", href: "/category/clothing" }, + { text: "Activewear", href: "/category/activewear" }, + { text: "Accessories", href: "/category/kids-accessories" }, + { text: "Footwear", href: "/category/footwear" }, + ], + }, + ], + }, + }, + { + text: "Electronics", + href: "/category/electronics", + submenu: { + variant: "text-grid", + items: [ + { + text: "Audio Devices", + href: "/category/audio-devices", + items: [ + { text: "Headphones", href: "/category/headphones" }, + { text: "Speakers", href: "/category/speakers" }, + ], + }, + { + text: "Cameras", + href: "/category/cameras", + items: [ + { text: "Digital Cameras", href: "/category/digital-cameras" }, + { text: "Action Cameras", href: "/category/action-cameras" }, + ], + }, + { + text: "Smartphones", + href: "/category/smartphones", + }, + { + text: "Laptops", + href: "/category/laptops", + }, + { + text: "Screens", + href: "/category/screens", + }, + ], + }, + }, + { + text: "Sports & Outdoors", + href: "/category/sports-and-outdoors", + submenu: { + variant: "text-grid", + items: [ + { + href: "/category/exercise-equipment", + text: "Exercise Equipment", + }, + { + href: "/category/outdoor-gear", + text: "Outdoor Gear", + }, + { + href: "/category/sportswear", + text: "Sportswear", + }, + { + href: "/category/athletic-footwear", + text: "Athletic Footwear", + }, + ], + }, + }, + { + text: "Beauty", + href: "/category/beauty", + submenu: { + variant: "text-grid", + items: [ + { + text: "Skin Care", + href: "/category/skin-care", + items: [ + { text: "Cleansers", href: "/category/cleansers" }, + { text: "Moisturizers", href: "/category/moisturizers" }, + { text: "Treatments & Serums", href: "/category/treatments-and-serums" }, + ], + }, + { + text: "Makeup", + href: "/category/makeup", + items: [ + { text: "Face Makeup", href: "/category/face-makeup" }, + { text: "Eye Makeup", href: "/category/eye-makeup" }, + { text: "Lip Makeup", href: "/category/lip-makeup" }, + ], + }, + { + text: "Haircare", + href: "/category/haircare", + items: [ + { text: "Shampoos & Conditioners", href: "/category/shampoos-and-conditioners" }, + { text: "Styling Products", href: "/category/styling-products" }, + ], + }, + + { + text: "Fragrances", + href: "/category/fragrances", + items: [ + { text: "Perfumes", href: "/category/perfumes" }, + { text: "Body Sprays", href: "/category/body-sprays" }, + ], + }, + ], + }, + }, + { + text: "Furniture", + href: "/category/furniture", + submenu: { + variant: "text-grid", + items: [ + { + text: "Living Room", + href: "/category/living-room-furniture", + items: [ + { text: "Sofas & Sectionals", href: "/category/sofas-and-sectionals" }, + { text: "Coffee Tables", href: "/category/coffee-tables" }, + { text: "TV Stands", href: "/category/tv-stands" }, + ], + }, + + { + text: "Bedroom", + href: "/category/bedroom-furniture", + items: [ + { text: "Beds", href: "/category/beds" }, + { text: "Dressers", href: "/category/dressers" }, + { text: "Nightstands", href: "/category/nightstands" }, + ], + }, + + { + text: "Office", + href: "/category/office-furniture", + items: [ + { text: "Desks", href: "/category/desks" }, + { text: "Office Chairs", href: "/category/office-chairs" }, + { text: "Storage Solutions", href: "/category/storage-solutions" }, + ], + }, + ], + }, + }, +] + +export const metadata: Metadata = { + title: "Next.js Enterprise Commerce | Blazity", + description: "AI-FIRST NEXT.JS STOREFRONT FOR COMPOSABLE COMMERCE", + metadataBase: new URL(env.LIVE_URL!), + openGraph: { + title: "Next.js Enterprise Commerce | Blazity", + description: "AI-FIRST NEXT.JS STOREFRONT FOR COMPOSABLE COMMERCE", + images: ["/opengraph-image.jpg"], + }, + twitter: { + card: "summary_large_image", + title: "Next.js Enterprise Commerce | Blazity", + description: "AI-FIRST NEXT.JS STOREFRONT FOR COMPOSABLE COMMERCE", + creator: "@blazity", + images: ["/opengraph-image.jpg"], + }, + verification: { + google: "google", + yandex: "yandex", + yahoo: "yahoo", + }, + generator: "Next.js", + applicationName: "Next.js", +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + +