Skip to content

Commit

Permalink
feature: algolia starter (#63)
Browse files Browse the repository at this point in the history
  • Loading branch information
ddaoxuan authored Oct 14, 2024
1 parent d9c0338 commit 8677d85
Show file tree
Hide file tree
Showing 309 changed files with 89,903 additions and 114 deletions.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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.

<img width="1841" alt="architecture diagram" src="https://github.com/Blazity/enterprise-commerce/assets/28964599/c5d3a0b3-6c3e-47df-9c45-4ecb583f5a64">

Expand Down
8 changes: 8 additions & 0 deletions starters/shopify-algolia/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.next
node_modules
gql

dist
/dist
dist/*
dist/**/*
39 changes: 39 additions & 0 deletions starters/shopify-algolia/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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",
},
}
22 changes: 22 additions & 0 deletions starters/shopify-algolia/.gitignore
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions starters/shopify-algolia/.graphqlrc.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
},
}
3 changes: 3 additions & 0 deletions starters/shopify-algolia/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.next
node_modules
gql
30 changes: 30 additions & 0 deletions starters/shopify-algolia/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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")))
}
17 changes: 17 additions & 0 deletions starters/shopify-algolia/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions starters/shopify-algolia/app/.well-known/vercel/flags/route.ts
Original file line number Diff line number Diff line change
@@ -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>(apiData)
}
13 changes: 13 additions & 0 deletions starters/shopify-algolia/app/access-denied/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Link from "next/link"

export default function AccessDenied() {
return (
<div className="mx-auto flex max-w-container-sm flex-col gap-16 px-4 py-32 text-4xl md:py-64">
<p>Looks like you don&apos;t have access to this page. If you were logged in before your session might&apos;ve expired. Please log in again! 😊 </p>

<Link href="/" className="text-2xl underline hover:no-underline">
Go Home
</Link>
</div>
)
}
86 changes: 86 additions & 0 deletions starters/shopify-algolia/app/actions/cart.actions.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
26 changes: 26 additions & 0 deletions starters/shopify-algolia/app/actions/collection.actions.ts
Original file line number Diff line number Diff line change
@@ -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<PlatformCollection>({
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 }
)
20 changes: 20 additions & 0 deletions starters/shopify-algolia/app/actions/favorites.actions.ts
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions starters/shopify-algolia/app/actions/page.actions.ts
Original file line number Diff line number Diff line change
@@ -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 })
Loading

0 comments on commit 8677d85

Please sign in to comment.