├── .markdownlint.json ├── public ├── robots.txt ├── images │ ├── 404.png │ ├── logo.png │ ├── aboutUs.png │ ├── avatar.png │ ├── banner.png │ ├── favicon.png │ ├── avatar-sm.png │ ├── og-image.png │ ├── product-1.png │ ├── category-1.png │ ├── category-2.png │ ├── payment │ │ ├── upay.png │ │ ├── visa.png │ │ ├── bkash.png │ │ ├── nagad.png │ │ ├── express.png │ │ └── mastercard.png │ ├── staff │ │ └── staff.png │ ├── call-to-action.png │ ├── logo-darkmode.png │ ├── image-placeholder.png │ ├── no-search-found.png │ ├── product-placeholder.jpg │ └── quote.svg └── .htaccess ├── .dockerignore ├── .vscode ├── extensions.json └── settings.json ├── netlify.toml ├── .env.example ├── src ├── lib │ ├── shopify │ │ ├── fragments │ │ │ ├── seo.ts │ │ │ ├── image.ts │ │ │ ├── cart.ts │ │ │ └── product.ts │ │ ├── queries │ │ │ ├── menu.ts │ │ │ ├── cart.ts │ │ │ ├── vendor.ts │ │ │ ├── page.ts │ │ │ ├── product.ts │ │ │ └── collection.ts │ │ └── mutations │ │ │ ├── customer.ts │ │ │ └── cart.ts │ ├── utils │ │ ├── taxonomyFilter.ts │ │ ├── dateFormat.ts │ │ ├── sortFunctions.ts │ │ ├── readingTime.ts │ │ ├── similarItems.ts │ │ ├── bgImageMod.ts │ │ ├── textConverter.ts │ │ └── cartActions.ts │ ├── typeGuards.ts │ ├── contentParser.astro │ ├── constants.ts │ ├── taxonomyParser.astro │ └── utils.ts ├── pages │ ├── sign-up.astro │ ├── 404.astro │ ├── api │ │ ├── products.json.ts │ │ ├── login.ts │ │ └── sign-up.ts │ ├── [regular].astro │ ├── index.astro │ ├── contact.astro │ ├── login.astro │ └── products │ │ └── index.astro ├── layouts │ ├── shortcodes │ │ ├── Tab.tsx │ │ ├── Youtube.tsx │ │ ├── Video.tsx │ │ ├── Button.tsx │ │ ├── Accordion.tsx │ │ ├── Tabs.tsx │ │ └── Notice.tsx │ ├── functional-components │ │ ├── cart │ │ │ ├── Cart.astro │ │ │ ├── CloseCart.tsx │ │ │ ├── OpenCart.tsx │ │ │ ├── DeleteItemButton.tsx │ │ │ ├── EditItemQuantityButton.tsx │ │ │ └── AddToCart.tsx │ │ ├── loadings │ │ │ ├── LoadingDots.tsx │ │ │ └── skeleton │ │ │ │ ├── SkeletonCategory.tsx │ │ │ │ ├── SkeletonProductThumb.tsx │ │ │ │ ├── SkeletonFeaturedProducts.tsx │ │ │ │ ├── SkeletonDescription.tsx │ │ │ │ ├── SkeletonCards.tsx │ │ │ │ ├── SkeletonProducts.tsx │ │ │ │ └── SkeletonProductGallery.tsx │ │ ├── Price.tsx │ │ ├── rangeSlider │ │ │ ├── rangeSlider.css │ │ │ └── RangeSlider.tsx │ │ ├── Accordion.tsx │ │ ├── ProductLayoutViews.tsx │ │ ├── product │ │ │ ├── ShowTags.tsx │ │ │ ├── PaymentSlider.tsx │ │ │ ├── Tabs.tsx │ │ │ └── VariantDropDown.tsx │ │ ├── HeroSlider.tsx │ │ ├── filter │ │ │ ├── FilterDropdownItem.tsx │ │ │ └── DropdownMenu.tsx │ │ ├── SearchBar.tsx │ │ ├── SocialShare.tsx │ │ ├── NavUser.tsx │ │ ├── CollectionsSlider.tsx │ │ └── SignUpForm.tsx │ ├── partials │ │ ├── PageHeader.astro │ │ ├── PostSidebar.astro │ │ ├── CallToAction.astro │ │ ├── Footer.astro │ │ └── Testimonials.astro │ ├── components │ │ ├── Price.astro │ │ ├── TwSizeIndicator.astro │ │ ├── Social.astro │ │ ├── Breadcrumbs.astro │ │ ├── ImageMod.astro │ │ ├── Share.astro │ │ ├── Logo.astro │ │ ├── FeaturedProducts.astro │ │ ├── ThemeSwitcher.astro │ │ └── Pagination.astro │ └── helpers │ │ ├── DynamicIcon.tsx │ │ └── Announcement.tsx ├── types │ ├── index.d.ts │ ├── sections │ │ ├── paymentCollection.ts │ │ └── ctaSectionCollection.ts │ └── pages │ │ ├── contactCollection.ts │ │ └── aboutCollection.ts ├── content │ ├── sections │ │ ├── call-to-action.md │ │ └── payments-and-delivery.md │ ├── contact │ │ └── -index.md │ └── pages │ │ └── privacy-policy.md ├── styles │ ├── buttons.css │ ├── main.css │ ├── base.css │ ├── safe.css │ ├── utilities.css │ ├── generated-theme.css │ └── navigation.css ├── config │ ├── social.json │ ├── theme.json │ ├── menu.json │ └── config.json ├── content.config.ts └── cartStore.ts ├── .prettierrc ├── .editorconfig ├── .gitignore ├── config └── nginx │ └── nginx.conf ├── tsconfig.json ├── LICENSE ├── Dockerfile ├── astro.config.mjs ├── package.json └── scripts └── removeDarkmode.js /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD033": false, 3 | "MD013": false 4 | } 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Disallow: /api/* -------------------------------------------------------------------------------- /public/images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/404.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/aboutUs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/aboutUs.png -------------------------------------------------------------------------------- /public/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/avatar.png -------------------------------------------------------------------------------- /public/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/banner.png -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/favicon.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git -------------------------------------------------------------------------------- /public/images/avatar-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/avatar-sm.png -------------------------------------------------------------------------------- /public/images/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/og-image.png -------------------------------------------------------------------------------- /public/images/product-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/product-1.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode","bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /public/images/category-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/category-1.png -------------------------------------------------------------------------------- /public/images/category-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/category-2.png -------------------------------------------------------------------------------- /public/images/payment/upay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/upay.png -------------------------------------------------------------------------------- /public/images/payment/visa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/visa.png -------------------------------------------------------------------------------- /public/images/staff/staff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/staff/staff.png -------------------------------------------------------------------------------- /public/images/call-to-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/call-to-action.png -------------------------------------------------------------------------------- /public/images/logo-darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/logo-darkmode.png -------------------------------------------------------------------------------- /public/images/payment/bkash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/bkash.png -------------------------------------------------------------------------------- /public/images/payment/nagad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/nagad.png -------------------------------------------------------------------------------- /public/images/image-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/image-placeholder.png -------------------------------------------------------------------------------- /public/images/no-search-found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/no-search-found.png -------------------------------------------------------------------------------- /public/images/payment/express.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/express.png -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist" 3 | command = "yarn build" 4 | 5 | [build.environment] 6 | NODE_VERSION = "22.21.1" 7 | -------------------------------------------------------------------------------- /public/images/payment/mastercard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/payment/mastercard.png -------------------------------------------------------------------------------- /public/images/product-placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeon-studio/storeplate/HEAD/public/images/product-placeholder.jpg -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.mdx": "markdown" 4 | }, 5 | "tailwindCSS.experimental.configFile": "src/styles/main.css" 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_SHOPIFY_API_SECRET_KEY="" 2 | PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN="" 3 | PUBLIC_SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" 4 | -------------------------------------------------------------------------------- /src/lib/shopify/fragments/seo.ts: -------------------------------------------------------------------------------- 1 | const seoFragment = /* GraphQL */ ` 2 | fragment seo on SEO { 3 | description 4 | title 5 | } 6 | `; 7 | 8 | export default seoFragment; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-astro"], 3 | "overrides": [ 4 | { 5 | "files": ["*.astro"], 6 | "options": { 7 | "parser": "astro" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/sign-up.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import SignUpForm from "@/functional-components/SignUpForm"; 3 | import Base from "@/layouts/Base.astro"; 4 | --- 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/shopify/fragments/image.ts: -------------------------------------------------------------------------------- 1 | const imageFragment = /* GraphQL */ ` 2 | fragment image on Image { 3 | url 4 | altText 5 | width 6 | height 7 | } 8 | `; 9 | 10 | export default imageFragment; 11 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Tab.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Tab({ name, children }: { name: string; children: React.ReactNode }) { 4 | return
{children}
; 5 | } 6 | 7 | export default Tab; 8 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Faq = { 2 | title: string; 3 | content: string; 4 | }; 5 | 6 | export type Testimonial = { 7 | name: string; 8 | designation: string; 9 | avatar: string; 10 | content: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/menu.ts: -------------------------------------------------------------------------------- 1 | export const getMenuQuery = /* GraphQL */ ` 2 | query getMenu($handle: String!) { 3 | menu(handle: $handle) { 4 | items { 5 | title 6 | url 7 | } 8 | } 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/cart.ts: -------------------------------------------------------------------------------- 1 | import cartFragment from "../fragments/cart"; 2 | 3 | export const getCartQuery = /* GraphQL */ ` 4 | query getCart($cartId: ID!) { 5 | cart(id: $cartId) { 6 | ...cart 7 | } 8 | } 9 | ${cartFragment} 10 | `; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/Cart.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import CartModal from "./CartModal"; 3 | 4 | // let cart; 5 | // const cartId = Astro.cookies.get("cartId")?.value; 6 | 7 | // if (cartId) { 8 | // cart = await getCart(cartId); 9 | // } 10 | --- 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/utils/taxonomyFilter.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from "@/lib/utils/textConverter"; 2 | 3 | const taxonomyFilter = (posts: any[], name: string, key: string) => 4 | posts.filter((post) => 5 | post.data[name].map((name: string) => slugify(name)).includes(key), 6 | ); 7 | 8 | export default taxonomyFilter; 9 | -------------------------------------------------------------------------------- /src/lib/utils/dateFormat.ts: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | const dateFormat = ( 4 | date: Date | string, 5 | pattern: string = "dd MMM, yyyy", 6 | ): string => { 7 | const dateObj = new Date(date); 8 | const output = format(dateObj, pattern); 9 | return output; 10 | }; 11 | 12 | export default dateFormat; 13 | -------------------------------------------------------------------------------- /src/content/sections/call-to-action.md: -------------------------------------------------------------------------------- 1 | --- 2 | enable: true 3 | title: "Curved Collection for Your 4 | Bedroom Get 25% Off" 5 | sub_title: "Deal of the Week" 6 | image: "/images/call-to-action.png" 7 | description: "Subscribe our Newsletter and get all latest information and offers" 8 | button: 9 | enable: true 10 | label: "Shop Now" 11 | link: "/products" 12 | --- 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .output/ 4 | .json/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | yarn.lock 15 | package-lock.json 16 | 17 | 18 | # environment variables 19 | .env 20 | .env.production 21 | 22 | # macOS-specific files 23 | .DS_Store 24 | 25 | # ignore .astro directory 26 | .astro 27 | 28 | .netlify 29 | .vercel 30 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/vendor.ts: -------------------------------------------------------------------------------- 1 | export const getVendorsQuery = /* GraphQL */ ` 2 | query getVendors { 3 | products(first: 250) { 4 | edges { 5 | node { 6 | vendor 7 | } 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export const getTagsQuery = /* GraphQL */ ` 14 | query getVendors { 15 | products(first: 250) { 16 | edges { 17 | node { 18 | tags 19 | } 20 | } 21 | } 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Youtube.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | const Youtube = ({ 4 | id, 5 | title, 6 | ...rest 7 | }: { 8 | id: string; 9 | title: string; 10 | [key: string]: any; 11 | }) => { 12 | useEffect(() => { 13 | import("@justinribeiro/lite-youtube"); 14 | }, []); 15 | 16 | // @ts-ignore 17 | return ; 18 | }; 19 | 20 | export default Youtube; 21 | -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/CloseCart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FaXmark } from "react-icons/fa6"; 3 | 4 | export default function CloseCart({ className }: { className?: string }) { 5 | return ( 6 |
7 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/buttons.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | @apply inline-block rounded-md border border-transparent px-5 py-2 font-semibold capitalize transition cursor-pointer; 3 | } 4 | 5 | .btn-primary { 6 | @apply border-primary bg-primary text-white dark:border-darkmode-primary dark:bg-white dark:text-text-dark text-center; 7 | } 8 | 9 | .btn-outline-primary { 10 | @apply border-dark bg-transparent text-text-dark hover:bg-dark hover:text-white dark:border-white dark:text-white dark:hover:bg-white dark:hover:text-text-dark; 11 | } 12 | -------------------------------------------------------------------------------- /src/layouts/partials/PageHeader.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Breadcrumbs from "@/components/Breadcrumbs.astro"; 3 | import { humanize } from "@/lib/utils/textConverter"; 4 | 5 | const { title = "" }: { title?: string } = Astro.props; 6 | --- 7 | 8 |
9 |
10 |
13 |

14 | 15 |

16 |
17 |
18 | -------------------------------------------------------------------------------- /src/config/social.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": [ 3 | { 4 | "name": "facebook", 5 | "icon": "FaFacebook", 6 | "link": "https://www.facebook.com/" 7 | }, 8 | { 9 | "name": "x", 10 | "icon": "FaXTwitter", 11 | "link": "https://x.com/" 12 | }, 13 | { 14 | "name": "github", 15 | "icon": "FaGithub", 16 | "link": "https://www.github.com/" 17 | }, 18 | { 19 | "name": "linkedin", 20 | "icon": "FaLinkedin", 21 | "link": "https://www.linkedin.com/" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/forms"; 3 | @plugin "@tailwindcss/typography"; 4 | @plugin 'tailwind-bootstrap-grid'; 5 | @custom-variant dark (&:where(.dark, .dark *)); 6 | 7 | /* Auto-generated theme from "theme.json"*/ 8 | @import "./generated-theme.css"; 9 | 10 | @import "./safe.css"; 11 | @import "./utilities.css"; 12 | 13 | @layer base { 14 | @import "./base.css"; 15 | } 16 | 17 | @layer components { 18 | @import "./components.css"; 19 | @import "./navigation.css"; 20 | @import "./buttons.css"; 21 | } 22 | -------------------------------------------------------------------------------- /src/content/sections/payments-and-delivery.md: -------------------------------------------------------------------------------- 1 | --- 2 | payment_methods: 3 | - name: "Visa" 4 | image_url: "/images/payment/visa.png" 5 | - name: "MasterCard" 6 | image_url: "/images/payment/mastercard.png" 7 | - name: "Express" 8 | image_url: "/images/payment/express.png" 9 | - name: "Bkash" 10 | image_url: "/images/payment/bkash.png" 11 | - name: "Nagad" 12 | image_url: "/images/payment/nagad.png" 13 | - name: "Upay" 14 | image_url: "/images/payment/upay.png" 15 | 16 | estimated_delivery: "Est. Delivery between 0 - 3 days" 17 | --- -------------------------------------------------------------------------------- /src/content/contact/-index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Connect with Us" 3 | meta_title: "" 4 | description: "this is meta description" 5 | image: "" 6 | draft: false 7 | 8 | #Contact Options 9 | contact_meta: 10 | - name: "Address" 11 | contact: "123 Main Street, Anytown,
CA 12335 - USA" 12 | 13 | - name: "Email" 14 | contact: "yourmail@domain.com
support@domain.com" 15 | 16 | - name: "Phone" 17 | contact: "Mobile: (08) 123 456 789
Hotline: 1009 678 456" 18 | 19 | - name: "Shop Time" 20 | contact: "Available at 10am-8pm
" 21 | --- 22 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md"; 4 | 5 | const LoadingDots = ({ className }: { className: string }) => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default LoadingDots; 16 | -------------------------------------------------------------------------------- /src/types/sections/paymentCollection.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "astro/loaders"; 2 | import { defineCollection, z } from "astro:content"; 3 | 4 | export const paymentCollection = defineCollection({ 5 | loader: glob({ 6 | pattern: "payments-and-delivery.{md,mdx}", 7 | base: "src/content/sections", 8 | }), 9 | schema: z.object({ 10 | payment_methods: z 11 | .array( 12 | z.object({ 13 | name: z.string(), 14 | image_url: z.string(), 15 | }), 16 | ) 17 | .optional(), 18 | estimated_delivery: z.string().optional(), 19 | }), 20 | }); 21 | -------------------------------------------------------------------------------- /src/layouts/components/Price.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { 3 | amount, 4 | className = "", 5 | currencyCode = "USD", 6 | currencyCodeClassName = "", 7 | } = Astro.props; 8 | 9 | const formattedAmount = new Intl.NumberFormat(undefined, { 10 | style: "currency", 11 | currency: currencyCode, 12 | currencyDisplay: "narrowSymbol", 13 | }).format(parseFloat(amount)); 14 | 15 | const combinedClassName = 16 | `${className} ${currencyCodeClassName ? "ml-1 inline" : ""}`.trim(); 17 | --- 18 | 19 |

20 | {formattedAmount} 21 | {currencyCode} 22 |

23 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonCategory.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | const SkeletonCategory = () => { 3 | return ( 4 |
5 | {Array(3) 6 | .fill(0) 7 | .map((_, index) => { 8 | return ( 9 |
13 | ); 14 | })} 15 |
16 | ); 17 | }; 18 | 19 | export default SkeletonCategory; 20 | -------------------------------------------------------------------------------- /src/types/sections/ctaSectionCollection.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "astro/loaders"; 2 | import { defineCollection, z } from "astro:content"; 3 | 4 | export const ctaSectionCollection = defineCollection({ 5 | loader: glob({ 6 | pattern: "call-to-action.{md,mdx}", 7 | base: "src/content/sections", 8 | }), 9 | schema: z.object({ 10 | enable: z.boolean(), 11 | title: z.string(), 12 | sub_title: z.string(), 13 | description: z.string(), 14 | image: z.string(), 15 | button: z.object({ 16 | enable: z.boolean(), 17 | label: z.string(), 18 | link: z.string(), 19 | }), 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /src/types/pages/contactCollection.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "astro/loaders"; 2 | import { defineCollection, z } from "astro:content"; 3 | 4 | export const contactCollection = defineCollection({ 5 | loader: glob({ pattern: "**/*.{md,mdx}", base: "src/content/contact" }), 6 | schema: z.object({ 7 | title: z.string(), 8 | meta_title: z.string().optional(), 9 | description: z.string(), 10 | image: z.string().optional(), 11 | draft: z.boolean(), 12 | 13 | contact_meta: z 14 | .array( 15 | z.object({ 16 | name: z.string(), 17 | contact: z.string(), 18 | }), 19 | ) 20 | .optional(), 21 | }), 22 | }); 23 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Video.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | function Video({ 3 | title, 4 | width = 500, 5 | height = "auto", 6 | src, 7 | ...rest 8 | }: { 9 | title: string; 10 | width: number; 11 | height: number | "auto"; 12 | src: string; 13 | [key: string]: any; 14 | }) { 15 | return ( 16 | 29 | ); 30 | } 31 | 32 | export default Video; 33 | -------------------------------------------------------------------------------- /src/layouts/components/TwSizeIndicator.astro: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | { 5 | process.env.NODE_ENV === "development" && ( 6 |
7 | all 8 | 9 | 10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Button = ({ 4 | label, 5 | link, 6 | style, 7 | rel, 8 | }: { 9 | label: string; 10 | link: string; 11 | style?: string; 12 | rel?: string; 13 | }) => { 14 | return ( 15 | 25 | {label} 26 | 27 | ); 28 | }; 29 | 30 | export default Button; 31 | -------------------------------------------------------------------------------- /src/layouts/components/Social.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { source, className } = Astro.props; 3 | import DynamicIcon from "@/helpers/DynamicIcon"; 4 | 5 | export interface ISocial { 6 | name: string; 7 | icon: string; 8 | link: string; 9 | } 10 | --- 11 | 12 | 29 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/page.ts: -------------------------------------------------------------------------------- 1 | import seoFragment from "../fragments/seo"; 2 | 3 | const pageFragment = /* GraphQL */ ` 4 | fragment page on Page { 5 | ... on Page { 6 | id 7 | title 8 | handle 9 | body 10 | bodySummary 11 | seo { 12 | ...seo 13 | } 14 | createdAt 15 | updatedAt 16 | } 17 | } 18 | ${seoFragment} 19 | `; 20 | 21 | export const getPageQuery = /* GraphQL */ ` 22 | query getPage($handle: String!) { 23 | pageByHandle(handle: $handle) { 24 | ...page 25 | } 26 | } 27 | ${pageFragment} 28 | `; 29 | 30 | export const getPagesQuery = /* GraphQL */ ` 31 | query getPages { 32 | pages(first: 100) { 33 | edges { 34 | node { 35 | ...page 36 | } 37 | } 38 | } 39 | } 40 | ${pageFragment} 41 | `; 42 | -------------------------------------------------------------------------------- /config/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 80; 10 | server_name _; 11 | 12 | root /usr/share/nginx/html; 13 | index index.html index.htm; 14 | include /etc/nginx/mime.types; 15 | 16 | gzip on; 17 | gzip_min_length 1000; 18 | gzip_proxied expired no-cache no-store private auth; 19 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 20 | 21 | error_page 404 /404.html; 22 | location = /404.html { 23 | root /usr/share/nginx/html; 24 | internal; 25 | } 26 | 27 | location / { 28 | try_files $uri ${uri}.html $uri/index.html =404; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/OpenCart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BsCart3 } from "react-icons/bs"; 3 | 4 | interface OpenCartProps { 5 | className?: string; 6 | quantity?: number; 7 | } 8 | 9 | const OpenCart: React.FC = ({ className = "", quantity }) => { 10 | return ( 11 |
12 | 13 | 14 | {quantity ? ( 15 |
16 | {quantity} 17 |
18 | ) : null} 19 |
20 | ); 21 | }; 22 | 23 | export default OpenCart; 24 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonProductThumb.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const SkeletonProductThumb = () => { 4 | return ( 5 |
6 |
7 |
8 | 9 |
10 | {Array(4) 11 | .fill(0) 12 | .map((_, index) => { 13 | return ( 14 |
18 | ); 19 | })} 20 |
21 |
22 |
23 | ); 24 | }; 25 | 26 | export default SkeletonProductThumb; 27 | -------------------------------------------------------------------------------- /src/lib/typeGuards.ts: -------------------------------------------------------------------------------- 1 | export interface ShopifyErrorLike { 2 | status: number; 3 | message: Error; 4 | cause?: Error; 5 | } 6 | 7 | export const isObject = ( 8 | object: unknown, 9 | ): object is Record => { 10 | return ( 11 | typeof object === "object" && object !== null && !Array.isArray(object) 12 | ); 13 | }; 14 | 15 | export const isShopifyError = (error: unknown): error is ShopifyErrorLike => { 16 | if (!isObject(error)) return false; 17 | 18 | if (error instanceof Error) return true; 19 | 20 | return findError(error); 21 | }; 22 | 23 | function findError(error: T): boolean { 24 | if (Object.prototype.toString.call(error) === "[object Error]") { 25 | return true; 26 | } 27 | 28 | const prototype = Object.getPrototypeOf(error) as T | null; 29 | 30 | return prototype === null ? false : findError(prototype); 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "target": "es6", 6 | "allowJs": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "jsx": "react", 11 | "isolatedModules": true, 12 | "incremental": true, 13 | "allowSyntheticDefaultImports": true, 14 | "paths": { 15 | "@/components/*": ["./src/layouts/components/*"], 16 | "@/functional-components/*": ["./src/layouts/functional-components/*"], 17 | "@/shortcodes/*": ["./src/layouts/shortcodes/*"], 18 | "@/helpers/*": ["./src/layouts/helpers/*"], 19 | "@/partials/*": ["./src/layouts/partials/*"], 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": [".astro/types.d.ts", "**/*.ts", "**/*.tsx", "**/*.astro"], 24 | "exclude": ["node_modules", "dist"] 25 | } 26 | -------------------------------------------------------------------------------- /public/images/quote.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Base from "@/layouts/Base.astro"; 3 | --- 4 | 5 | 6 |
7 |
8 |
9 |
10 | 13 | 404 14 | 15 |

Page not found

16 |
17 |

18 | The page you are looking for might have been removed, had its name 19 | changed, or is temporarily unavailable. 20 |

21 |
22 | Back to home 23 |
24 |
25 |
26 |
27 | 28 | -------------------------------------------------------------------------------- /src/layouts/functional-components/Price.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface PriceProps { 4 | amount: string; 5 | className?: string; 6 | currencyCode?: string; 7 | currencyCodeClassName?: string; 8 | } 9 | 10 | const Price: React.FC = ({ 11 | amount, 12 | className = "", 13 | currencyCode = "USD", 14 | currencyCodeClassName = "", 15 | }) => { 16 | const formattedAmount = new Intl.NumberFormat(undefined, { 17 | style: "currency", 18 | currency: currencyCode, 19 | currencyDisplay: "narrowSymbol", 20 | }).format(parseFloat(amount)); 21 | 22 | const combinedClassName = `${className} ${ 23 | currencyCodeClassName ? "ml-1 inline" : "" 24 | }`.trim(); 25 | 26 | return ( 27 |

28 | {formattedAmount} 29 | {currencyCode} 30 |

31 | ); 32 | }; 33 | 34 | export default Price; 35 | -------------------------------------------------------------------------------- /src/lib/utils/sortFunctions.ts: -------------------------------------------------------------------------------- 1 | // sort by date 2 | export const sortByDate = (array: any[]) => { 3 | const sortedArray = array.sort( 4 | (a: any, b: any) => 5 | new Date(b.data.date && b.data.date).valueOf() - 6 | new Date(a.data.date && a.data.date).valueOf(), 7 | ); 8 | return sortedArray; 9 | }; 10 | 11 | // sort product by weight 12 | export const sortByWeight = (array: any[]) => { 13 | const withWeight = array.filter( 14 | (item: { data: { weight: any } }) => item.data.weight, 15 | ); 16 | const withoutWeight = array.filter( 17 | (item: { data: { weight: any } }) => !item.data.weight, 18 | ); 19 | const sortedWeightedArray = withWeight.sort( 20 | (a: { data: { weight: number } }, b: { data: { weight: number } }) => 21 | a.data.weight - b.data.weight, 22 | ); 23 | const sortedArray = [...new Set([...sortedWeightedArray, ...withoutWeight])]; 24 | return sortedArray; 25 | }; 26 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonFeaturedProducts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const SkeletonFeaturedProducts = () => { 4 | return ( 5 |
6 | {Array(8) 7 | .fill(0) 8 | .map((_, index) => { 9 | return ( 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ); 18 | })} 19 |
20 | ); 21 | }; 22 | 23 | export default SkeletonFeaturedProducts; 24 | -------------------------------------------------------------------------------- /src/styles/base.css: -------------------------------------------------------------------------------- 1 | html { 2 | @apply text-base-sm md:text-base; 3 | } 4 | 5 | body { 6 | @apply bg-body text-base dark:bg-darkmode-body font-primary font-normal leading-relaxed text-text dark:text-darkmode-text; 7 | } 8 | 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6 { 15 | @apply font-primary font-bold leading-tight text-text-dark dark:text-darkmode-text-dark; 16 | } 17 | 18 | h1, 19 | .h1 { 20 | @apply text-h1-sm md:text-h1; 21 | } 22 | 23 | h2, 24 | .h2 { 25 | @apply text-h2-sm md:text-h2; 26 | } 27 | 28 | h3, 29 | .h3 { 30 | @apply text-h3-sm md:text-h3; 31 | } 32 | 33 | h4, 34 | .h4 { 35 | @apply text-h4; 36 | } 37 | 38 | h5, 39 | .h5 { 40 | @apply text-h5; 41 | } 42 | 43 | h6, 44 | .h6 { 45 | @apply text-h6; 46 | } 47 | 48 | b, 49 | strong { 50 | @apply font-semibold; 51 | } 52 | 53 | code { 54 | @apply after:border-none; 55 | } 56 | 57 | blockquote > p { 58 | @apply my-0!; 59 | } 60 | 61 | button { 62 | @apply cursor-pointer; 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/utils/readingTime.ts: -------------------------------------------------------------------------------- 1 | // content reading 2 | const readingTime = (content: string): string => { 3 | const WPS = 275 / 60; 4 | 5 | let images = 0; 6 | const regex = /\w/; 7 | 8 | let words = content.split(" ").filter((word) => { 9 | if (word.includes(" 3) { 22 | imageFactor -= 1; 23 | } 24 | images -= 1; 25 | } 26 | 27 | const minutes = Math.ceil(((words - imageAdjust) / WPS + imageSecs) / 60); 28 | 29 | if (minutes < 10) { 30 | if (minutes < 2) { 31 | return "0" + minutes + ` Min read`; 32 | } else { 33 | return "0" + minutes + ` Mins read`; 34 | } 35 | } else { 36 | return minutes + ` Mins read`; 37 | } 38 | }; 39 | 40 | export default readingTime; 41 | -------------------------------------------------------------------------------- /src/pages/api/products.json.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { getProducts } from "@/lib/shopify"; 3 | 4 | export const GET: APIRoute = async ({ request }) => { 5 | const url = new URL(request.url); 6 | const cursor = url.searchParams.get("cursor"); 7 | const sortKey = url.searchParams.get("sortKey") as string; 8 | const reverse = url.searchParams.get("reverse") === "true"; 9 | 10 | try { 11 | const { products, pageInfo } = await getProducts({ 12 | sortKey, 13 | reverse, 14 | cursor: cursor || undefined, 15 | }); 16 | 17 | return new Response(JSON.stringify({ products, pageInfo }), { 18 | status: 200, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | }); 23 | } catch (error) { 24 | console.error("Error fetching products:", error); 25 | return new Response(JSON.stringify({ error: "Failed to fetch products" }), { 26 | status: 500, 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/config/theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors": { 3 | "default": { 4 | "theme_color": { 5 | "primary": "#121212", 6 | "body": "#fff", 7 | "border": "#eaeaea", 8 | "light": "#f2f2f2", 9 | "dark": "#000" 10 | }, 11 | "text_color": { 12 | "text": "#444", 13 | "text-dark": "#000", 14 | "text-light": "#666" 15 | } 16 | }, 17 | "darkmode": { 18 | "theme_color": { 19 | "primary": "#fff", 20 | "body": "#252525", 21 | "border": "#3E3E3E", 22 | "light": "#222222", 23 | "dark": "#000" 24 | }, 25 | "text_color": { 26 | "text": "#DDD", 27 | "text-dark": "#fff", 28 | "text-light": "#DDD" 29 | } 30 | } 31 | }, 32 | "fonts": { 33 | "font_family": { 34 | "primary": "Karla:wght@400;500;700", 35 | "primary_type": "sans-serif", 36 | "secondary": "", 37 | "secondary_type": "" 38 | }, 39 | "font_size": { 40 | "base": "16", 41 | "scale": "1.2" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "astro/loaders"; 2 | import { defineCollection, z } from "astro:content"; 3 | import { aboutCollection } from "./types/pages/aboutCollection"; 4 | import { contactCollection } from "./types/pages/contactCollection"; 5 | import { ctaSectionCollection } from "./types/sections/ctaSectionCollection"; 6 | import { paymentCollection } from "./types/sections/paymentCollection"; 7 | 8 | // Pages collection schema 9 | const pagesCollection = defineCollection({ 10 | loader: glob({ pattern: "**/*.{md,mdx}", base: "src/content/pages" }), 11 | schema: z.object({ 12 | title: z.string(), 13 | meta_title: z.string().optional(), 14 | description: z.string().optional(), 15 | image: z.string().optional(), 16 | draft: z.boolean().optional(), 17 | }), 18 | }); 19 | 20 | // Export collections 21 | export const collections = { 22 | // Pages 23 | pages: pagesCollection, 24 | about: aboutCollection, 25 | contact: contactCollection, 26 | 27 | // sections 28 | ctaSection: ctaSectionCollection, 29 | paymentSection: paymentCollection, 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/utils/similarItems.ts: -------------------------------------------------------------------------------- 1 | // similar products 2 | const similarItems = (currentItem: any, allItems: any[]) => { 3 | let categories: string[] = []; 4 | let tags: string[] = []; 5 | 6 | // set categories 7 | if (currentItem.data.categories.length > 0) { 8 | categories = currentItem.data.categories; 9 | } 10 | 11 | // set tags 12 | if (currentItem.data.tags.length > 0) { 13 | tags = currentItem.data.tags; 14 | } 15 | 16 | // filter by categories 17 | const filterByCategories = allItems.filter((item: any) => 18 | categories.find((category) => item.data.categories.includes(category)), 19 | ); 20 | 21 | // filter by tags 22 | const filterByTags = allItems.filter((item: any) => 23 | tags.find((tag) => item.data.tags.includes(tag)), 24 | ); 25 | 26 | // merged after filter 27 | const mergedItems = [...new Set([...filterByCategories, ...filterByTags])]; 28 | 29 | // filter by slug 30 | const filterBySlug = mergedItems.filter( 31 | (product) => product.slug !== currentItem.slug, 32 | ); 33 | 34 | return filterBySlug; 35 | }; 36 | 37 | export default similarItems; 38 | -------------------------------------------------------------------------------- /src/lib/contentParser.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { 3 | getCollection, 4 | getEntry, 5 | type CollectionEntry, 6 | type CollectionKey, 7 | } from "astro:content"; 8 | 9 | type PageData = { 10 | title: string; 11 | meta_title?: string; 12 | description?: string; 13 | image?: string; 14 | draft?: boolean; 15 | }; 16 | 17 | export const getSinglePage = async ( 18 | collectionName: C 19 | ): Promise[]> => { 20 | const allPages = await getCollection( 21 | collectionName, 22 | ({ data, id }) => !(data as PageData)?.draft && !id.startsWith("-") 23 | ); 24 | return allPages; 25 | }; 26 | 27 | export const getListPage = async ( 28 | collectionName: C, 29 | documentId: "-index" | string 30 | ): Promise> => { 31 | const data = (await getEntry( 32 | collectionName, 33 | documentId 34 | )) as CollectionEntry | null; 35 | 36 | if (!data) { 37 | throw new Error( 38 | `No page found for the collection: ${collectionName} with filename: ${documentId}` 39 | ); 40 | } 41 | 42 | return data; 43 | }; 44 | --- 45 | -------------------------------------------------------------------------------- /src/lib/shopify/mutations/customer.ts: -------------------------------------------------------------------------------- 1 | export const createCustomerMutation = /* GraphQL */ ` 2 | mutation customerCreate($input: CustomerCreateInput!) { 3 | customerCreate(input: $input) { 4 | customer { 5 | firstName 6 | lastName 7 | email 8 | phone 9 | acceptsMarketing 10 | } 11 | customerUserErrors { 12 | code 13 | field 14 | message 15 | } 16 | } 17 | } 18 | `; 19 | 20 | export const getCustomerAccessTokenMutation = /* GraphQL */ ` 21 | mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { 22 | customerAccessTokenCreate(input: $input) { 23 | customerAccessToken { 24 | accessToken 25 | } 26 | customerUserErrors { 27 | code 28 | field 29 | message 30 | } 31 | } 32 | } 33 | `; 34 | 35 | export const getUserDetailsQuery = /* GraphQL */ ` 36 | query getOrders($input: String!) { 37 | customer(customerAccessToken: $input) { 38 | id 39 | firstName 40 | lastName 41 | acceptsMarketing 42 | email 43 | phone 44 | } 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 - Present, Zeon Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/shopify/fragments/cart.ts: -------------------------------------------------------------------------------- 1 | import productFragment from "./product"; 2 | 3 | const cartFragment = /* GraphQL */ ` 4 | fragment cart on Cart { 5 | id 6 | checkoutUrl 7 | cost { 8 | subtotalAmount { 9 | amount 10 | currencyCode 11 | } 12 | totalAmount { 13 | amount 14 | currencyCode 15 | } 16 | totalTaxAmount { 17 | amount 18 | currencyCode 19 | } 20 | } 21 | lines(first: 100) { 22 | edges { 23 | node { 24 | id 25 | quantity 26 | cost { 27 | totalAmount { 28 | amount 29 | currencyCode 30 | } 31 | } 32 | merchandise { 33 | ... on ProductVariant { 34 | id 35 | title 36 | selectedOptions { 37 | name 38 | value 39 | } 40 | product { 41 | ...product 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | totalQuantity 49 | } 50 | ${productFragment} 51 | `; 52 | 53 | export default cartFragment; 54 | -------------------------------------------------------------------------------- /src/config/menu.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": [ 3 | { 4 | "name": "Home", 5 | "url": "/" 6 | }, 7 | { 8 | "name": "Products", 9 | "url": "/products" 10 | }, 11 | { 12 | "name": "Pages", 13 | "url": "", 14 | "hasChildren": true, 15 | "children": [ 16 | { 17 | "name": "About", 18 | "url": "/about" 19 | }, 20 | { 21 | "name": "Contact", 22 | "url": "/contact" 23 | }, 24 | { 25 | "name": "404 Page", 26 | "url": "/404" 27 | } 28 | ] 29 | }, 30 | { 31 | "name": "Contact", 32 | "url": "/contact" 33 | } 34 | ], 35 | "footer": [ 36 | { 37 | "name": "About", 38 | "url": "/about" 39 | }, 40 | { 41 | "name": "Products", 42 | "url": "/products" 43 | }, 44 | { 45 | "name": "Contact", 46 | "url": "/contact" 47 | } 48 | ], 49 | "footerCopyright": [ 50 | { 51 | "name": "Privacy & Policy", 52 | "url": "/privacy-policy" 53 | }, 54 | { 55 | "name": "Terms of Service", 56 | "url": "/terms-services" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/shopify/mutations/cart.ts: -------------------------------------------------------------------------------- 1 | import cartFragment from "../fragments/cart"; 2 | 3 | export const addToCartMutation = /* GraphQL */ ` 4 | mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) { 5 | cartLinesAdd(cartId: $cartId, lines: $lines) { 6 | cart { 7 | ...cart 8 | } 9 | } 10 | } 11 | ${cartFragment} 12 | `; 13 | 14 | export const createCartMutation = /* GraphQL */ ` 15 | mutation createCart($lineItems: [CartLineInput!]) { 16 | cartCreate(input: { lines: $lineItems }) { 17 | cart { 18 | ...cart 19 | } 20 | } 21 | } 22 | ${cartFragment} 23 | `; 24 | 25 | export const editCartItemsMutation = /* GraphQL */ ` 26 | mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) { 27 | cartLinesUpdate(cartId: $cartId, lines: $lines) { 28 | cart { 29 | ...cart 30 | } 31 | } 32 | } 33 | ${cartFragment} 34 | `; 35 | 36 | export const removeFromCartMutation = /* GraphQL */ ` 37 | mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) { 38 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { 39 | cart { 40 | ...cart 41 | } 42 | } 43 | } 44 | ${cartFragment} 45 | `; 46 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonDescription.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | const SkeletonDescription = () => { 5 | return ( 6 |
7 |
8 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default SkeletonDescription; 26 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | const Accordion = ({ 4 | title, 5 | children, 6 | className, 7 | }: { 8 | title: string; 9 | children: React.ReactNode; 10 | className?: string; 11 | }) => { 12 | const [show, setShow] = useState(false); 13 | 14 | return ( 15 |
16 | 31 |
{children}
32 |
33 | ); 34 | }; 35 | 36 | export default Accordion; 37 | -------------------------------------------------------------------------------- /src/layouts/functional-components/rangeSlider/rangeSlider.css: -------------------------------------------------------------------------------- 1 | .range-slider-container { 2 | padding: 0 0; 3 | margin: 0 0; 4 | } 5 | 6 | .range-slider { 7 | position: relative; 8 | height: 8px; 9 | margin: 10px 0 15px; 10 | } 11 | 12 | .slider-track { 13 | position: absolute; 14 | width: 100%; 15 | height: 8px; 16 | background-color: #e7e7e7; 17 | border-radius: 10px; 18 | top: 50%; 19 | transform: translateY(-50%); 20 | } 21 | 22 | .slider-range { 23 | position: absolute; 24 | height: 8px; 25 | background-color: gray; 26 | top: 50%; 27 | transform: translateY(-50%); 28 | } 29 | 30 | .slider-thumb { 31 | position: absolute; 32 | width: 20px; 33 | height: 20px; 34 | background-color: white; 35 | border: 3px solid gray; 36 | border-radius: 50%; 37 | top: 50%; 38 | transform: translate(-50%, -50%); 39 | cursor: pointer; 40 | z-index: 2; 41 | } 42 | 43 | .slider-thumb:hover { 44 | box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.1); 45 | } 46 | 47 | .slider-thumb:active { 48 | box-shadow: 0 0 0 12px rgba(0, 0, 0, 0.1); 49 | cursor: grabbing; 50 | } 51 | 52 | /* For touch devices */ 53 | @media (pointer: coarse) { 54 | .slider-thumb { 55 | width: 24px; 56 | height: 24px; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/styles/safe.css: -------------------------------------------------------------------------------- 1 | /* navbar toggler */ 2 | input#nav-toggle:checked + label #show-button { 3 | @apply hidden; 4 | } 5 | 6 | input#nav-toggle:checked + label #hide-button { 7 | @apply block; 8 | } 9 | 10 | input#nav-toggle:checked ~ #nav-menu { 11 | @apply block; 12 | } 13 | 14 | /* swiper pagination */ 15 | .testimonial-slider-pagination { 16 | .swiper-pagination-bullet { 17 | @apply h-2.5 w-2.5 bg-light opacity-100 dark:bg-darkmode-light; 18 | } 19 | 20 | .swiper-pagination-bullet-active { 21 | @apply h-4 w-4 bg-primary dark:bg-darkmode-primary; 22 | } 23 | } 24 | 25 | .iiz__hint { 26 | @apply rounded-md; 27 | } 28 | 29 | /* change input autofield color */ 30 | input:-webkit-autofill, 31 | input:-webkit-autofill:hover, 32 | input:-webkit-autofill:focus, 33 | input:-webkit-autofill:active { 34 | -webkit-box-shadow: 0 0 0 30px #f2f2f2 inset !important; 35 | } 36 | 37 | .dark input:-webkit-autofill, 38 | .dark input:-webkit-autofill:hover, 39 | .dark input:-webkit-autofill:focus, 40 | .dark input:-webkit-autofill:active { 41 | -webkit-box-shadow: 0 0 0 30px rgba(0, 0, 0, 0.9) inset !important; 42 | } 43 | 44 | /*Change text in autofill textbox*/ 45 | .dark input:-webkit-autofill { 46 | -webkit-text-fill-color: #ddd !important; 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/[regular].astro: -------------------------------------------------------------------------------- 1 | --- 2 | export const prerender = true; 3 | import Base from "@/layouts/Base.astro"; 4 | import { getSinglePage } from "@/lib/contentParser.astro"; 5 | import PageHeader from "@/partials/PageHeader.astro"; 6 | import { render } from "astro:content"; 7 | 8 | // get static paths for all pages 9 | export async function getStaticPaths() { 10 | const COLLECTION_FOLDER = "pages"; 11 | 12 | const pages = await getSinglePage(COLLECTION_FOLDER); 13 | 14 | const paths = pages.map((page) => ({ 15 | params: { 16 | regular: page.id, 17 | }, 18 | props: { page }, 19 | })); 20 | return paths; 21 | } 22 | 23 | const { page } = Astro.props; 24 | const { title, meta_title, description, image } = page?.data; 25 | const { Content } = await render(page); 26 | --- 27 | 28 | 34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 | 47 | -------------------------------------------------------------------------------- /src/styles/utilities.css: -------------------------------------------------------------------------------- 1 | .bg-gradient { 2 | @apply bg-gradient-to-r from-[#F4F4F4] to-[#F4F4F43D] dark:from-darkmode-light dark:to-darkmode-body; 3 | } 4 | 5 | .rounded-sm { 6 | @apply rounded-[4px]; 7 | } 8 | .rounded { 9 | @apply rounded-[6px]; 10 | } 11 | .rounded-lg { 12 | @apply rounded-[12px]; 13 | } 14 | .rounded-xl { 15 | @apply rounded-[16px]; 16 | } 17 | 18 | .shadow { 19 | box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.05); 20 | } 21 | 22 | .search-input::-webkit-search-cancel-button { 23 | -webkit-appearance: none; 24 | } 25 | 26 | @utility btn-sm { 27 | @apply px-4 py-1.5 text-sm font-medium; 28 | } 29 | 30 | @utility btn-md { 31 | @apply px-8 py-4 text-xl font-medium; 32 | } 33 | 34 | @utility btn-lg { 35 | @apply px-12 py-4 text-xl; 36 | } 37 | 38 | @utility section-sm { 39 | @apply py-16 xl:py-20; 40 | } 41 | 42 | /* form style */ 43 | @utility form-input { 44 | @apply w-full rounded border-transparent bg-light px-6 py-4 text-text-dark placeholder:text-text-light focus:border-primary dark:focus:border-darkmode-primary focus:ring-transparent dark:border-darkmode-border dark:bg-darkmode-light dark:text-darkmode-light; 45 | } 46 | 47 | @utility form-label { 48 | @apply mb-4 block font-secondary text-xl font-normal text-text-dark dark:text-darkmode-light; 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export type SortFilterItem = { 2 | title: string; 3 | slug: string | null; 4 | sortKey: "RELEVANCE" | "BEST_SELLING" | "CREATED_AT" | "PRICE"; 5 | reverse: boolean; 6 | }; 7 | 8 | export const defaultSort: SortFilterItem = { 9 | title: "Relevance", 10 | slug: null, 11 | sortKey: "CREATED_AT", 12 | reverse: false, 13 | }; 14 | 15 | export const sorting: SortFilterItem[] = [ 16 | defaultSort, 17 | { 18 | title: "Trending", 19 | slug: "trending-desc", 20 | sortKey: "BEST_SELLING", 21 | reverse: false, 22 | }, // asc 23 | { 24 | title: "Latest arrivals", 25 | slug: "latest-desc", 26 | sortKey: "CREATED_AT", 27 | reverse: true, 28 | }, 29 | { 30 | title: "Price: Low to high", 31 | slug: "price-asc", 32 | sortKey: "PRICE", 33 | reverse: false, 34 | }, // asc 35 | { 36 | title: "Price: High to low", 37 | slug: "price-desc", 38 | sortKey: "PRICE", 39 | reverse: true, 40 | }, 41 | ]; 42 | 43 | export const TAGS = { 44 | collections: "collections", 45 | products: "products", 46 | cart: "cart", 47 | }; 48 | 49 | export const HIDDEN_PRODUCT_TAG = "nextjs-frontend-hidden"; 50 | export const DEFAULT_OPTION = "Default Title"; 51 | export const SHOPIFY_GRAPHQL_API_ENDPOINT = "/api/2023-01/graphql.json"; 52 | -------------------------------------------------------------------------------- /src/lib/taxonomyParser.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getSinglePage } from "@/lib/contentParser.astro"; 3 | import { slugify } from "@/lib/utils/textConverter"; 4 | 5 | // get taxonomy from frontmatter 6 | export const getTaxonomy = async (collection: any, name: string) => { 7 | const singlePages = await getSinglePage(collection); 8 | const taxonomyPages = singlePages.map((page: any) => page.data[name]); 9 | let taxonomies: string[] = []; 10 | for (let i = 0; i < taxonomyPages.length; i++) { 11 | const categoryArray = taxonomyPages[i]; 12 | for (let j = 0; j < categoryArray.length; j++) { 13 | taxonomies.push(slugify(categoryArray[j])); 14 | } 15 | } 16 | const taxonomy = [...new Set(taxonomies)]; 17 | return taxonomy; 18 | }; 19 | 20 | // get all taxonomies from frontmatter 21 | export const getAllTaxonomy = async (collection: any, name: string) => { 22 | const singlePages = await getSinglePage(collection); 23 | const taxonomyPages = singlePages.map((page: any) => page.data[name]); 24 | let taxonomies: string[] = []; 25 | for (let i = 0; i < taxonomyPages.length; i++) { 26 | const categoryArray = taxonomyPages[i]; 27 | for (let j = 0; j < categoryArray.length; j++) { 28 | taxonomies.push(slugify(categoryArray[j])); 29 | } 30 | } 31 | return taxonomies; 32 | }; 33 | --- 34 | -------------------------------------------------------------------------------- /src/lib/utils/bgImageMod.ts: -------------------------------------------------------------------------------- 1 | import { getImage } from "astro:assets"; 2 | 3 | const bgImageMod = async ( 4 | src: string, 5 | format?: "auto" | "avif" | "jpeg" | "png" | "svg" | "webp", 6 | ) => { 7 | src = `/public${src}`; 8 | const images = import.meta.glob("/public/images/**/*.{jpeg,jpg,png,gif}"); 9 | 10 | // Check if the source path is valid 11 | if (!src || !images[src]) { 12 | console.error( 13 | `\x1b[31mImage not found - ${src}.\x1b[0m Make sure the image is in the /public/images folder.`, 14 | ); 15 | 16 | return ""; // Return an empty string if the image is not found 17 | } 18 | 19 | // Function to get the image info like width, height, format, etc. 20 | const getImagePath = async (image: string) => { 21 | try { 22 | const imageData = (await images[image]()) as any; 23 | return imageData; 24 | } catch (error) { 25 | return `Image not found - ${src}. Make sure the image is in the /public/images folder.`; 26 | } 27 | }; 28 | 29 | // Get the image data for the specified source path 30 | const image = await getImagePath(src); 31 | 32 | // Optimize the image for development 33 | const ImageMod = await getImage({ 34 | src: image.default, 35 | format: format, 36 | }); 37 | 38 | return ImageMod.src; 39 | }; 40 | 41 | export default bgImageMod; 42 | -------------------------------------------------------------------------------- /src/layouts/helpers/DynamicIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from "react"; 2 | import type { IconType } from "react-icons"; 3 | import * as FaIcons from "react-icons/fa6"; 4 | // import * as AiIcons from "react-icons/ai"; 5 | // import * as BsIcons from "react-icons/bs"; 6 | // import * as FiIcons from "react-icons/fi"; 7 | // import * as Io5Icons from "react-icons/io5"; 8 | // import * as RiIcons from "react-icons/ri"; 9 | // import * as TbIcons from "react-icons/tb"; 10 | // import * as TfiIcons from "react-icons/tfi"; 11 | 12 | type IconMap = Record; 13 | 14 | interface IDynamicIcon extends React.SVGProps { 15 | icon: string; 16 | className?: string; 17 | } 18 | 19 | const iconLibraries: { [key: string]: IconMap } = { 20 | fa: FaIcons, 21 | }; 22 | 23 | const DynamicIcon: FC = ({ icon, ...props }) => { 24 | const IconLibrary = getIconLibrary(icon); 25 | const Icon = IconLibrary ? IconLibrary[icon] : undefined; 26 | 27 | if (!Icon) { 28 | return Icon not found; 29 | } 30 | 31 | return ; 32 | }; 33 | 34 | const getIconLibrary = (icon: string): IconMap | undefined => { 35 | const libraryKey = icon.substring(0, 2).toLowerCase(); 36 | 37 | return iconLibraries[libraryKey]; 38 | }; 39 | 40 | export default DynamicIcon; 41 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonCards.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | const SkeletonCards = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | {Array(9) 12 | .fill(0) 13 | .map((_, index) => { 14 | return ( 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ); 23 | })} 24 |
25 |
26 |
27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default SkeletonCards; 34 | -------------------------------------------------------------------------------- /src/styles/generated-theme.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Auto-generated from "src/config/theme.json" 3 | * DO NOT EDIT THIS FILE MANUALLY 4 | * Run: node scripts/themeGenerator.js 5 | */ 6 | 7 | @theme { 8 | /* === Colors === */ 9 | --color-primary: #121212; 10 | --color-body: #fff; 11 | --color-border: #eaeaea; 12 | --color-light: #f2f2f2; 13 | --color-dark: #000; 14 | --color-text: #444; 15 | --color-text-dark: #000; 16 | --color-text-light: #666; 17 | 18 | /* === Darkmode Colors === */ 19 | --color-darkmode-primary: #fff; 20 | --color-darkmode-body: #252525; 21 | --color-darkmode-border: #3E3E3E; 22 | --color-darkmode-light: #222222; 23 | --color-darkmode-dark: #000; 24 | --color-darkmode-text: #DDD; 25 | --color-darkmode-text-dark: #fff; 26 | --color-darkmode-text-light: #DDD; 27 | 28 | /* === Font Families === */ 29 | --font-primary: Karla, sans-serif; 30 | --font-secondary: , sans-serif; 31 | 32 | /* === Font Sizes === */ 33 | --text-base: 16px; 34 | --text-base-sm: 12.8px; 35 | --text-h6: 1.2rem; 36 | --text-h6-sm: 1.08rem; 37 | --text-h5: 1.44rem; 38 | --text-h5-sm: 1.296rem; 39 | --text-h4: 1.728rem; 40 | --text-h4-sm: 1.5552rem; 41 | --text-h3: 2.0736rem; 42 | --text-h3-sm: 1.86624rem; 43 | --text-h2: 2.48832rem; 44 | --text-h2-sm: 2.239488rem; 45 | --text-h1: 2.9859839999999997rem; 46 | --text-h1-sm: 2.6873856rem; 47 | } 48 | -------------------------------------------------------------------------------- /src/layouts/components/Breadcrumbs.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { humanize } from "@/lib/utils/textConverter"; 3 | 4 | const { className }: { className?: string } = Astro.props; 5 | 6 | const paths = Astro.url.pathname.split("/").filter((x) => x); 7 | let parts = [ 8 | { 9 | label: "Home", 10 | href: "/", 11 | "aria-label": Astro.url.pathname === "/" ? "page" : undefined, 12 | }, 13 | ]; 14 | 15 | paths.forEach((label: string, i: number) => { 16 | const href = `/${paths.slice(0, i + 1).join("/")}`; 17 | label !== "page" && 18 | parts.push({ 19 | label: humanize(label.replace(".html", "").replace(/[-_]/g, " ")) || "", 20 | href, 21 | "aria-label": Astro.url.pathname === href ? "page" : undefined, 22 | }); 23 | }); 24 | --- 25 | 26 | 46 | -------------------------------------------------------------------------------- /src/layouts/functional-components/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import type { Faq } from "@/types"; 2 | import React, { useState } from "react"; 3 | 4 | const Accordion = ({ faqs }: { faqs: Faq[] }) => { 5 | const [activeTab, setActiveTab] = useState(0); 6 | 7 | return ( 8 | <> 9 | {faqs.map((faq: Faq, index) => ( 10 |
14 | 34 |
{faq.content}
35 |
36 | ))} 37 | 38 | ); 39 | }; 40 | 41 | export default Accordion; 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG INSTALLER=yarn 2 | 3 | FROM node:22.20.0-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | ARG INSTALLER 8 | 9 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 10 | RUN apk add --no-cache libc6-compat 11 | WORKDIR /app 12 | 13 | # Install dependencies based on the preferred package manager 14 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 15 | RUN \ 16 | if [ "${INSTALLER}" == "yarn" ]; then yarn --frozen-lockfile; \ 17 | elif [ "${INSTALLER}" == "npm" ]; then npm ci; \ 18 | elif [ "${INSTALLER}" == "pnpm" ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ 19 | else echo "Valid installer not set." && exit 1; \ 20 | fi 21 | 22 | 23 | # Rebuild the source code only when needed 24 | FROM base AS builder 25 | WORKDIR /app 26 | COPY --from=deps /app/node_modules ./node_modules 27 | COPY . . 28 | 29 | # RUN chmod u+x ./installer && ./installer 30 | ARG INSTALLER 31 | RUN \ 32 | if [ "${INSTALLER}" == "yarn" ]; then yarn build; \ 33 | elif [ "${INSTALLER}" == "npm" ]; then npm run build; \ 34 | elif [ "${INSTALLER}" == "pnpm" ]; then pnpm run build; \ 35 | else echo "Valid installer not set." && exit 1; \ 36 | fi 37 | 38 | # Production image, copy all the files and run nginx 39 | FROM nginx:alpine AS runner 40 | COPY ./config/nginx/nginx.conf /etc/nginx/nginx.conf 41 | COPY --from=builder /app/dist /usr/share/nginx/html 42 | 43 | WORKDIR /usr/share/nginx/html 44 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import mdx from "@astrojs/mdx"; 2 | import netlify from "@astrojs/netlify"; 3 | import react from "@astrojs/react"; 4 | import sitemap from "@astrojs/sitemap"; 5 | import tailwindcss from "@tailwindcss/vite"; 6 | import AutoImport from "astro-auto-import"; 7 | import { defineConfig } from "astro/config"; 8 | import remarkCollapse from "remark-collapse"; 9 | import remarkToc from "remark-toc"; 10 | import sharp from "sharp"; 11 | import config from "./src/config/config.json"; 12 | 13 | // https://astro.build/config 14 | export default defineConfig({ 15 | site: config.site.base_url ? config.site.base_url : "http://examplesite.com", 16 | base: config.site.base_path ? config.site.base_path : "/", 17 | trailingSlash: config.site.trailing_slash ? "always" : "never", 18 | image: { service: sharp() }, 19 | output: "server", 20 | adapter: netlify({ 21 | edgeMiddleware: true, 22 | }), 23 | vite: { plugins: [tailwindcss()] }, 24 | integrations: [ 25 | react(), 26 | sitemap(), 27 | AutoImport({ 28 | imports: [ 29 | "@/shortcodes/Button", 30 | "@/shortcodes/Accordion", 31 | "@/shortcodes/Notice", 32 | "@/shortcodes/Video", 33 | "@/shortcodes/Youtube", 34 | "@/shortcodes/Tabs", 35 | "@/shortcodes/Tab", 36 | ], 37 | }), 38 | mdx(), 39 | ], 40 | markdown: { 41 | remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]], 42 | shikiConfig: { theme: "one-dark-pro", wrap: true }, 43 | extendDefaultPlugins: true, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /src/layouts/functional-components/ProductLayoutViews.tsx: -------------------------------------------------------------------------------- 1 | import { layoutView } from '@/cartStore'; 2 | import type { PageInfo, Product } from '@/lib/shopify/types'; 3 | import { useStore } from '@nanostores/react'; 4 | import React, { Suspense, lazy } from 'react'; 5 | import SkeletonCards from './loadings/skeleton/SkeletonCards'; 6 | 7 | const ProductGrid = lazy(() => import('./ProductGrid')); 8 | const ProductList = lazy(() => import('./ProductList')); 9 | 10 | const ProductLayoutViews = ({ 11 | initialProducts, 12 | initialPageInfo, 13 | sortKey, 14 | reverse, 15 | searchValue, 16 | }: { 17 | initialProducts: Product[]; 18 | initialPageInfo: PageInfo; 19 | sortKey: string; 20 | reverse: boolean; 21 | searchValue: string | null; 22 | }) => { 23 | const layout = useStore(layoutView); 24 | 25 | return ( 26 |
27 | }> 28 | {layout === 'list' ? ( 29 | 36 | ) : ( 37 | 44 | )} 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default ProductLayoutViews; 51 | -------------------------------------------------------------------------------- /src/layouts/components/ImageMod.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { ImageMetadata } from "astro"; 3 | import { Image } from "astro:assets"; 4 | 5 | // Props interface for the component 6 | interface Props { 7 | src: string; 8 | alt: string; 9 | width: number; 10 | height: number; 11 | loading?: "eager" | "lazy" | null | undefined; 12 | decoding?: "async" | "auto" | "sync" | null | undefined; 13 | format?: "auto" | "avif" | "jpeg" | "png" | "svg" | "webp"; 14 | class?: string; 15 | style?: any; 16 | } 17 | 18 | // Destructuring Astro.props to get the component's props 19 | let { 20 | src, 21 | alt, 22 | width, 23 | height, 24 | loading, 25 | decoding, 26 | class: className, 27 | format, 28 | style, 29 | } = Astro.props; 30 | 31 | src = `/public${src}`; 32 | 33 | // Glob pattern to load images from the /public/images folder 34 | const images = import.meta.glob("/public/images/**/*.{jpeg,jpg,png,gif}"); 35 | 36 | // Check if the source path is valid 37 | const isValidPath = images[src] ? true : false; 38 | 39 | // Log a warning message in red if the image is not found 40 | !isValidPath && 41 | console.error( 42 | `\x1b[31mImage not found - ${src}.\x1b[0m Make sure the image is in the /public/images folder.`, 43 | ); 44 | --- 45 | 46 | { 47 | isValidPath && ( 48 | } 50 | alt={alt} 51 | width={width} 52 | height={height} 53 | loading={loading} 54 | decoding={decoding} 55 | class={className} 56 | format={format} 57 | style={style} 58 | /> 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/product.ts: -------------------------------------------------------------------------------- 1 | import productFragment from "../fragments/product"; 2 | 3 | export const getProductQuery = /* GraphQL */ ` 4 | query getProduct($handle: String!) { 5 | product(handle: $handle) { 6 | ...product 7 | } 8 | } 9 | ${productFragment} 10 | `; 11 | 12 | export const getProductsQuery = /* GraphQL */ ` 13 | query getProducts( 14 | $sortKey: ProductSortKeys 15 | $reverse: Boolean 16 | $query: String 17 | $cursor: String 18 | ) { 19 | products( 20 | sortKey: $sortKey 21 | reverse: $reverse 22 | query: $query 23 | first: 12 24 | after: $cursor 25 | ) { 26 | pageInfo { 27 | hasNextPage 28 | hasPreviousPage 29 | endCursor 30 | } 31 | edges { 32 | node { 33 | ...product 34 | } 35 | } 36 | } 37 | } 38 | ${productFragment} 39 | `; 40 | 41 | export const getProductRecommendationsQuery = /* GraphQL */ ` 42 | query getProductRecommendations($productId: ID!) { 43 | productRecommendations(productId: $productId) { 44 | ...product 45 | } 46 | } 47 | ${productFragment} 48 | `; 49 | 50 | export const getHighestProductPriceQuery = /* GraphQL */ ` 51 | query getHighestProductPrice { 52 | products(first: 1, sortKey: PRICE, reverse: true) { 53 | edges { 54 | node { 55 | variants(first: 1) { 56 | edges { 57 | node { 58 | price { 59 | amount 60 | currencyCode 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | `; 70 | -------------------------------------------------------------------------------- /src/layouts/functional-components/product/ShowTags.tsx: -------------------------------------------------------------------------------- 1 | import { slugify } from "@/lib/utils/textConverter"; 2 | import React, { useState } from "react"; 3 | 4 | const ShowTags = ({ tags }: { tags: string[] }) => { 5 | const [searchParams, setSearchParams] = useState( 6 | new URLSearchParams(window.location.search) 7 | ); 8 | const selectedTag = searchParams.get("t"); 9 | 10 | const updateSearchParams = (newParams: URLSearchParams) => { 11 | const newParamsString = newParams.toString(); 12 | const newURL = newParamsString 13 | ? `/products?${newParamsString}` 14 | : "/products"; 15 | 16 | window.location.href = newURL.toString(); 17 | setSearchParams(newParams); 18 | }; 19 | 20 | const handleTagClick = (name: string) => { 21 | const slugName = slugify(name.toLowerCase()); 22 | const newParams = new URLSearchParams(searchParams.toString()); 23 | 24 | if (slugName === selectedTag) { 25 | newParams.delete("t"); 26 | } else { 27 | newParams.set("t", slugName); 28 | } 29 | 30 | updateSearchParams(newParams); 31 | }; 32 | 33 | return ( 34 |
35 | {tags.map((tag) => ( 36 | 45 | ))} 46 |
47 | ); 48 | }; 49 | 50 | export default ShowTags; 51 | -------------------------------------------------------------------------------- /src/lib/shopify/queries/collection.ts: -------------------------------------------------------------------------------- 1 | import productFragment from "../fragments/product"; 2 | import seoFragment from "../fragments/seo"; 3 | 4 | const collectionFragment = /* GraphQL */ ` 5 | fragment collection on Collection { 6 | handle 7 | title 8 | description 9 | image { 10 | altText 11 | url 12 | } 13 | seo { 14 | ...seo 15 | } 16 | updatedAt 17 | products(first: 100) { 18 | edges { 19 | node { 20 | id 21 | } 22 | } 23 | } 24 | } 25 | ${seoFragment} 26 | `; 27 | 28 | export const getCollectionQuery = /* GraphQL */ ` 29 | query getCollection($handle: String!) { 30 | collection(handle: $handle) { 31 | ...collection 32 | } 33 | } 34 | ${collectionFragment} 35 | `; 36 | 37 | export const getCollectionsQuery = /* GraphQL */ ` 38 | query getCollections { 39 | collections(first: 100, sortKey: TITLE) { 40 | edges { 41 | node { 42 | ...collection 43 | } 44 | } 45 | } 46 | } 47 | ${collectionFragment} 48 | `; 49 | 50 | export const getCollectionProductsQuery = /* GraphQL */ ` 51 | query getCollectionProducts( 52 | $handle: String! 53 | $sortKey: ProductCollectionSortKeys 54 | $reverse: Boolean 55 | $filterCategoryProduct: [ProductFilter!] 56 | ) { 57 | collection(handle: $handle) { 58 | products( 59 | sortKey: $sortKey 60 | reverse: $reverse 61 | first: 100 62 | filters: $filterCategoryProduct 63 | ) { 64 | edges { 65 | node { 66 | ...product 67 | } 68 | } 69 | } 70 | } 71 | } 72 | ${productFragment} 73 | `; 74 | -------------------------------------------------------------------------------- /src/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "site": { 3 | "title": "Storeplate", 4 | "base_url": "https://storeplate.vercel.app/", 5 | "base_path": "/", 6 | "trailing_slash": false, 7 | "favicon": "/images/favicon.png", 8 | "logo": "/images/logo.png", 9 | "logo_darkmode": "/images/logo-darkmode.png", 10 | "logo_width": "150", 11 | "logo_height": "33", 12 | "logo_text": "Storeplate" 13 | }, 14 | 15 | "announcement": { 16 | "enable": true, 17 | "content": "♥️ Loving Storeplate? Please ⭐️ on Github", 18 | "expire_days": 7 19 | }, 20 | 21 | "settings": { 22 | "search": true, 23 | "account": true, 24 | "sticky_header": true, 25 | "theme_switcher": true, 26 | "default_theme": "system" 27 | }, 28 | 29 | "params": { 30 | "contact_form_action": "#", 31 | "copyright": "Designed And Developed by [Zeon Studio](https://zeon.studio)" 32 | }, 33 | 34 | "navigation_button": { 35 | "enable": true, 36 | "label": "Get Started", 37 | "link": "https://github.com/zeon-studio/storeplate" 38 | }, 39 | 40 | "google_tag_manager": { 41 | "enable": false, 42 | "gtm_id": "GTM-XXXXXX" 43 | }, 44 | 45 | "metadata": { 46 | "meta_author": "zeon.studio", 47 | "meta_image": "/images/og-image.png", 48 | "meta_description": "Shopify Storefront Boilerplate" 49 | }, 50 | 51 | "shopify": { 52 | "currencySymbol": "৳", 53 | "currencyCode": "BDT", 54 | "collections": { 55 | "hero_slider": "hidden-homepage-carousel", 56 | "featured_products": "featured-products" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/layouts/functional-components/product/PaymentSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { BsChevronRight } from "react-icons/bs"; 3 | // Import Swiper styles 4 | import "swiper/css"; 5 | import "swiper/css/navigation"; 6 | import "swiper/css/pagination"; 7 | import { Navigation, Pagination } from "swiper/modules"; 8 | import { Swiper, SwiperSlide } from "swiper/react"; 9 | 10 | const PaymentSlider = ({ paymentMethods }: { paymentMethods: any }) => { 11 | const [_, setInit] = useState(false); 12 | 13 | const prevRef = useRef(null); 14 | const nextRef = useRef(null); 15 | return ( 16 | setInit(true)} 37 | > 38 | {paymentMethods.map((item: any) => ( 39 | 40 | {item.paymentMethodName} 46 | 47 | ))} 48 | 49 | 53 | 54 | ); 55 | }; 56 | 57 | export default PaymentSlider; 58 | -------------------------------------------------------------------------------- /src/layouts/partials/PostSidebar.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { humanize } from "@/lib/utils/textConverter"; 3 | 4 | const { tags, categories, allCategories } = Astro.props; 5 | --- 6 | 7 |
8 | {/* categories */} 9 |
10 |
Categories
11 |
12 |
    13 | { 14 | categories.map((category: any) => { 15 | const count = allCategories.filter( 16 | (c: any) => c === category 17 | ).length; 18 | return ( 19 |
  • 20 | 24 | {humanize(category)} ({count}) 25 | 26 |
  • 27 | ); 28 | }) 29 | } 30 |
31 |
32 |
33 | 34 | {/* tags */} 35 |
36 |
Tags
37 |
38 | 54 |
55 |
56 |
57 | -------------------------------------------------------------------------------- /src/layouts/components/Share.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "@/config/config.json"; 3 | import { 4 | IoLogoFacebook, 5 | IoLogoLinkedin, 6 | IoLogoPinterest, 7 | IoLogoTwitter, 8 | } from "react-icons/io5"; 9 | 10 | const { base_url }: { base_url: string } = config.site; 11 | const { 12 | title, 13 | description, 14 | slug, 15 | className, 16 | }: { title?: string; description?: string; slug?: string; className?: string } = 17 | Astro.props; 18 | --- 19 | 20 | 62 | -------------------------------------------------------------------------------- /src/lib/shopify/fragments/product.ts: -------------------------------------------------------------------------------- 1 | import imageFragment from "./image"; 2 | import seoFragment from "./seo"; 3 | 4 | const productFragment = /* GraphQL */ ` 5 | fragment product on Product { 6 | id 7 | handle 8 | availableForSale 9 | title 10 | description 11 | descriptionHtml 12 | options { 13 | id 14 | name 15 | values 16 | } 17 | priceRange { 18 | maxVariantPrice { 19 | amount 20 | currencyCode 21 | } 22 | minVariantPrice { 23 | amount 24 | currencyCode 25 | } 26 | } 27 | compareAtPriceRange { 28 | maxVariantPrice { 29 | amount 30 | currencyCode 31 | } 32 | } 33 | variants(first: 250) { 34 | edges { 35 | node { 36 | id 37 | title 38 | availableForSale 39 | selectedOptions { 40 | name 41 | value 42 | } 43 | price { 44 | amount 45 | currencyCode 46 | } 47 | compareAtPrice { 48 | amount 49 | currencyCode 50 | } 51 | } 52 | } 53 | } 54 | featuredImage { 55 | ...image 56 | } 57 | images(first: 20) { 58 | edges { 59 | node { 60 | ...image 61 | } 62 | } 63 | } 64 | seo { 65 | ...seo 66 | } 67 | tags 68 | updatedAt 69 | vendor 70 | collections(first: 100) { 71 | nodes { 72 | title 73 | products(first: 100) { 74 | edges { 75 | node { 76 | title 77 | vendor 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | ${imageFragment} 85 | ${seoFragment} 86 | `; 87 | 88 | export default productFragment; 89 | -------------------------------------------------------------------------------- /src/layouts/partials/CallToAction.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ImageMod from "@/components/ImageMod.astro"; 3 | import { getListPage } from "@/lib/contentParser.astro"; 4 | import { markdownify } from "@/lib/utils/textConverter"; 5 | 6 | const call_to_action = await getListPage("ctaSection", "call-to-action"); 7 | 8 | const { data } = call_to_action; 9 | --- 10 | 11 | { 12 | data.enable && ( 13 |
14 |
15 |
16 |
17 |
18 |

22 |

23 |

27 | 28 | {data.button.enable && ( 29 | 33 | {data.button.label} 34 | 35 | )} 36 |

37 | 38 |
39 | 46 |
47 |
48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/utils/textConverter.ts: -------------------------------------------------------------------------------- 1 | import { slug } from "github-slugger"; 2 | import { marked } from "marked"; 3 | 4 | // slugify 5 | export const slugify = (content: string) => { 6 | return slug(content); 7 | }; 8 | 9 | // markdownify 10 | export const markdownify = (content: string, div?: boolean) => { 11 | return div ? marked.parse(content) : marked.parseInline(content); 12 | }; 13 | 14 | // humanize 15 | export const humanize = (content: string) => { 16 | return content 17 | .replace(/^[\s_]+|[\s_]+$/g, "") 18 | .replace(/[_\s]+/g, " ") 19 | .replace(/[-\s]+/g, " ") 20 | .replace(/^[a-z]/, function (m) { 21 | return m.toUpperCase(); 22 | }); 23 | }; 24 | 25 | // titleify 26 | export const titleify = (content: string) => { 27 | const humanized = humanize(content); 28 | return humanized 29 | .split(" ") 30 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 31 | .join(" "); 32 | }; 33 | 34 | // plainify 35 | export const plainify = (content: string) => { 36 | const parseMarkdown: any = marked.parse(content); 37 | const filterBrackets = parseMarkdown.replace(/<\/?[^>]+(>|$)/gm, ""); 38 | const filterSpaces = filterBrackets.replace(/[\r\n]\s*[\r\n]/gm, ""); 39 | const stripHTML = htmlEntityDecoder(filterSpaces); 40 | return stripHTML; 41 | }; 42 | 43 | // strip entities for plainify 44 | const htmlEntityDecoder = (htmlWithEntities: string) => { 45 | let entityList: { [key: string]: string } = { 46 | " ": " ", 47 | "<": "<", 48 | ">": ">", 49 | "&": "&", 50 | """: '"', 51 | "'": "'", 52 | }; 53 | let htmlWithoutEntities: string = htmlWithEntities.replace( 54 | /(&|<|>|"|')/g, 55 | (entity: string): string => { 56 | return entityList[entity]; 57 | }, 58 | ); 59 | return htmlWithoutEntities; 60 | }; 61 | -------------------------------------------------------------------------------- /src/layouts/components/Logo.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "@/config/config.json"; 3 | import ImageMod from "./ImageMod.astro"; 4 | 5 | const { src, srcDarkmode }: { src?: string; srcDarkmode?: string } = 6 | Astro.props; 7 | const { 8 | logo, 9 | logo_darkmode, 10 | logo_width, 11 | logo_height, 12 | logo_text, 13 | title, 14 | }: { 15 | logo: string; 16 | logo_darkmode: string; 17 | logo_width: any; 18 | logo_height: any; 19 | logo_text: string; 20 | title: string; 21 | } = config.site; 22 | 23 | const { theme_switcher }: { theme_switcher: boolean } = config.settings; 24 | --- 25 | 26 | 27 | { 28 | src || srcDarkmode || logo || logo_darkmode ? ( 29 | <> 30 | 42 | {theme_switcher && ( 43 | 55 | )} 56 | 57 | ) : logo_text ? ( 58 | logo_text 59 | ) : ( 60 | title 61 | ) 62 | } 63 | 64 | -------------------------------------------------------------------------------- /src/styles/navigation.css: -------------------------------------------------------------------------------- 1 | .header { 2 | @apply bg-body py-6 dark:bg-darkmode-body; 3 | } 4 | 5 | /* navbar items */ 6 | .navbar { 7 | @apply relative flex flex-wrap items-center justify-between; 8 | } 9 | 10 | .navbar-brand { 11 | @apply text-xl font-semibold text-dark dark:text-darkmode-dark; 12 | image { 13 | @apply max-h-full max-w-full; 14 | } 15 | } 16 | 17 | .navbar-nav { 18 | @apply text-center; 19 | } 20 | 21 | .nav-link { 22 | @apply block p-3 font-bold text-2xl transition text-text-light hover:text-text-dark dark:!text-darkmode-text dark:hover:!text-darkmode-primary lg:px-2 lg:py-3 cursor-pointer; 23 | } 24 | 25 | .nav-list { 26 | @apply space-y-2; 27 | } 28 | 29 | /* .nav-link { 30 | @apply text-gray-700 dark:text-gray-200; 31 | } */ 32 | 33 | .submenu-arrow.active { 34 | @apply rotate-180; 35 | } 36 | 37 | .nav-active { 38 | @apply text-text-dark dark:text-darkmode-text-dark; 39 | } 40 | 41 | .nav-dropdown { 42 | @apply mr-0; 43 | } 44 | 45 | .nav-dropdown-list { 46 | @apply z-10 rounded bg-body p-4 shadow dark:bg-darkmode-dark; 47 | } 48 | 49 | .nav-dropdown-item { 50 | @apply mb-2; 51 | } 52 | 53 | .nav-dropdown-link { 54 | @apply block py-1 font-medium text-xl text-text-light transition hover:!text-darkmode-text-dark dark:!text-darkmode-text dark:hover:text-darkmode-primary; 55 | } 56 | 57 | /* theme-switcher */ 58 | .theme-switcher { 59 | @apply inline-flex; 60 | 61 | label { 62 | @apply relative inline-block h-4 w-6 cursor-pointer rounded-2xl bg-border lg:w-10; 63 | } 64 | 65 | input { 66 | @apply absolute opacity-0; 67 | } 68 | 69 | span { 70 | @apply absolute -top-1 left-0 dark:lg:left-4 flex h-6 w-6 items-center justify-center rounded-full bg-dark transition-all duration-300 dark:bg-white; 71 | } 72 | 73 | input:checked + label { 74 | span { 75 | @apply lg:left-4; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/types/pages/aboutCollection.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "astro/loaders"; 2 | import { defineCollection, z } from "astro:content"; 3 | 4 | export const aboutCollection = defineCollection({ 5 | loader: glob({ pattern: "**/*.{md,mdx}", base: "src/content/about" }), 6 | schema: z.object({ 7 | title: z.string(), 8 | meta_title: z.string().optional(), 9 | description: z.string().optional(), 10 | image: z.string().optional(), 11 | draft: z.boolean(), 12 | 13 | // About Us section with a list of items 14 | about_us: z.array( 15 | z.object({ 16 | title: z.string(), 17 | image: z.string(), 18 | content: z.string(), 19 | }), 20 | ), 21 | 22 | // Frequently Asked Questions section 23 | faq_section_title: z.string().optional(), 24 | faq_section_subtitle: z.string().optional(), 25 | button: z 26 | .object({ 27 | enable: z.boolean(), 28 | label: z.string(), 29 | link: z.string(), 30 | }) 31 | .optional(), 32 | faqs: z.array( 33 | z.object({ 34 | title: z.string(), 35 | content: z.string(), 36 | }), 37 | ), 38 | 39 | // Testimonials section 40 | testimonials_section_enable: z.boolean().optional(), 41 | testimonials_section_title: z.string().optional(), 42 | testimonials: z 43 | .array( 44 | z.object({ 45 | name: z.string(), 46 | designation: z.string(), 47 | avatar: z.string(), 48 | content: z.string(), 49 | }), 50 | ) 51 | .optional(), 52 | 53 | // Staff section 54 | staff_section_enable: z.boolean().optional(), 55 | staff: z 56 | .array( 57 | z.object({ 58 | name: z.string(), 59 | designation: z.string(), 60 | avatar: z.string(), 61 | }), 62 | ) 63 | .optional(), 64 | }), 65 | }); 66 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a URL by combining pathname and search params 3 | */ 4 | export const createUrl = ( 5 | pathname: string, 6 | params: URLSearchParams, 7 | ): string => { 8 | const paramsString = params.toString(); 9 | const queryString = `${paramsString.length ? "?" : ""}${paramsString}`; 10 | 11 | return `${pathname}${queryString}`; 12 | }; 13 | 14 | /** 15 | * Ensures a string starts with a specified prefix 16 | */ 17 | export const ensureStartsWith = ( 18 | stringToCheck: string, 19 | startsWith: string, 20 | ): string => 21 | stringToCheck.startsWith(startsWith) 22 | ? stringToCheck 23 | : `${startsWith}${stringToCheck}`; 24 | 25 | /** 26 | * Validates required environment variables for Shopify integration 27 | */ 28 | export const validateEnvironmentVariables = (): void => { 29 | const requiredEnvironmentVariables = [ 30 | "PUBLIC_SHOPIFY_STORE_DOMAIN", 31 | "SHOPIFY_STOREFRONT_ACCESS_TOKEN", 32 | ]; 33 | const missingEnvironmentVariables: string[] = []; 34 | 35 | requiredEnvironmentVariables.forEach((envVar) => { 36 | if (!import.meta.env[envVar]) { 37 | missingEnvironmentVariables.push(envVar); 38 | } 39 | }); 40 | 41 | if (missingEnvironmentVariables.length) { 42 | throw new Error( 43 | `The following environment variables are missing. Your site will not work without them. Read more: https://docs.astro.build/en/guides/environment-variables/\n\n${missingEnvironmentVariables.join( 44 | "\n", 45 | )}\n`, 46 | ); 47 | } 48 | 49 | if ( 50 | import.meta.env.PUBLIC_SHOPIFY_STORE_DOMAIN?.includes("[") || 51 | import.meta.env.PUBLIC_SHOPIFY_STORE_DOMAIN?.includes("]") 52 | ) { 53 | throw new Error( 54 | "Your `PUBLIC_SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.", 55 | ); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/pages/api/login.ts: -------------------------------------------------------------------------------- 1 | import { getCustomerAccessToken, getUserDetails } from "@/lib/shopify"; 2 | 3 | // Exporting the handler function for the API route 4 | export const POST = async ({ request }: { request: Request }) => { 5 | try { 6 | const { email, password } = await request.json(); 7 | 8 | if (!email || !password) { 9 | return new Response( 10 | JSON.stringify({ 11 | errors: [{ message: "Email and password are required." }], 12 | }), 13 | { status: 400, headers: { "Content-Type": "application/json" } }, 14 | ); 15 | } 16 | 17 | // Get the customer token via Shopify API 18 | const { token, customerLoginErrors } = await getCustomerAccessToken({ 19 | email, 20 | password, 21 | }); 22 | 23 | if (customerLoginErrors?.length > 0) { 24 | return new Response(JSON.stringify({ errors: customerLoginErrors }), { 25 | status: 400, 26 | headers: { "Content-Type": "application/json" }, 27 | }); 28 | } 29 | 30 | // Fetch customer details using the token 31 | const { customer } = await getUserDetails(token); 32 | 33 | const response = new Response(JSON.stringify({ ...customer, token }), { 34 | status: 200, 35 | headers: { "Content-Type": "application/json" }, 36 | }); 37 | 38 | // Set token in cookie with HttpOnly flag 39 | response.headers.set("Set-Cookie", `token=${token}; Path=/; SameSite=Lax`); 40 | 41 | return response; 42 | } catch (error: any) { 43 | console.error("Error during login:", error); 44 | 45 | return new Response( 46 | JSON.stringify({ 47 | errors: [ 48 | { 49 | code: "INTERNAL_ERROR", 50 | message: error.message || "An unknown error occurred", 51 | }, 52 | ], 53 | }), 54 | { status: 500, headers: { "Content-Type": "application/json" } }, 55 | ); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import FeaturedProducts from "@/components/FeaturedProducts.astro"; 3 | import config from "@/config/config.json"; 4 | import CollectionsSlider from "@/functional-components/CollectionsSlider"; 5 | import Base from "@/layouts/Base.astro"; 6 | import { getCollectionProducts, getCollections } from "@/lib/shopify"; 7 | import CallToAction from "@/partials/CallToAction.astro"; 8 | import HeroSlider from "src/layouts/functional-components/HeroSlider"; 9 | 10 | const { collections } = config.shopify; 11 | 12 | // Fetch slider images for the HeroSlider 13 | const sliderImages = await getCollectionProducts({ 14 | collection: collections.hero_slider, 15 | }); 16 | const heroProducts = sliderImages.products; 17 | 18 | const collectionProducts = await getCollections(); 19 | 20 | // Fetch featured products 21 | const { products: featuredProducts } = await getCollectionProducts({ 22 | collection: collections.featured_products, 23 | reverse: false, 24 | }); 25 | --- 26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 | {/* category section */} 37 |
38 |
39 |
40 |

Collections

41 |
42 | 43 |
44 |
45 | 46 | {/* Featured Products section */} 47 |
48 |
49 |
50 |

Featured Products

51 |

Explore Today's Featured Picks!

52 |
53 | 54 |
55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/pages/api/sign-up.ts: -------------------------------------------------------------------------------- 1 | import { createCustomer, getCustomerAccessToken } from "@/lib/shopify"; 2 | import type { APIRoute } from "astro"; 3 | 4 | export const POST: APIRoute = async ({ request }) => { 5 | try { 6 | const formData = await request.formData(); 7 | const firstName = formData.get("firstName")?.toString(); 8 | const email = formData.get("email")?.toString(); 9 | const password = formData.get("password")?.toString(); 10 | 11 | if (!email || !password || !firstName) { 12 | return new Response("Email and password are required", { status: 400 }); 13 | } 14 | 15 | // Create customer via Shopify API 16 | const { customer, customerCreateErrors } = await createCustomer({ 17 | email, 18 | password, 19 | firstName, 20 | }); 21 | 22 | if (customerCreateErrors && customerCreateErrors.length > 0) { 23 | return new Response(JSON.stringify({ errors: customerCreateErrors }), { 24 | status: 400, 25 | headers: { "Content-Type": "application/json" }, 26 | }); 27 | } 28 | 29 | // Generate token 30 | const { token } = await getCustomerAccessToken({ email, password }); 31 | 32 | const response = new Response(JSON.stringify({ customer, token }), { 33 | status: 200, 34 | headers: { "Content-Type": "application/json" }, 35 | }); 36 | 37 | // Set the authentication token in a cookie without HttpOnly 38 | response.headers.set("Set-Cookie", `token=${token}; Path=/; SameSite=Lax`); 39 | 40 | return response; 41 | } catch (error: any) { 42 | console.error("Error in API:", error); 43 | return new Response( 44 | JSON.stringify({ 45 | errors: [ 46 | { 47 | code: "INTERNAL_ERROR", 48 | message: error.message || "An unknown error occurred", 49 | }, 50 | ], 51 | }), 52 | { 53 | status: 500, 54 | headers: { "Content-Type": "application/json" }, 55 | }, 56 | ); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonProducts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const SkeletonProducts = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {Array(9) 21 | .fill(0) 22 | .map((_, index) => { 23 | return ( 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ); 32 | })} 33 |
34 |
35 |
36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default SkeletonProducts; 43 | -------------------------------------------------------------------------------- /src/lib/utils/cartActions.ts: -------------------------------------------------------------------------------- 1 | import Cookies from "js-cookie"; 2 | import { 3 | addToCart, 4 | createCart, 5 | getCart, 6 | removeFromCart, 7 | updateCart, 8 | } from "@/lib/shopify"; 9 | 10 | export async function addItem(selectedVariantId: string | undefined) { 11 | let cartId = Cookies.get("cartId"); 12 | let cart; 13 | 14 | if (cartId) { 15 | cart = await getCart(cartId); 16 | } 17 | 18 | if (!cartId || !cart) { 19 | cart = await createCart(); 20 | cartId = cart.id; 21 | Cookies.set("cartId", cartId); 22 | } 23 | 24 | if (!selectedVariantId) { 25 | return "Missing product variant ID"; 26 | } 27 | 28 | try { 29 | await addToCart(cartId, [ 30 | { merchandiseId: selectedVariantId, quantity: 1 }, 31 | ]); 32 | // return (window.location.href = "/"); 33 | } catch (e) { 34 | return "Error adding item to cart"; 35 | } 36 | } 37 | 38 | export async function removeItem(lineId: string) { 39 | const cartId = Cookies.get("cartId"); 40 | 41 | if (!cartId) { 42 | return "Missing cart ID"; 43 | } 44 | 45 | try { 46 | await removeFromCart(cartId, [lineId]); 47 | // return (window.location.href = "/"); 48 | } catch (e) { 49 | return "Error removing item from cart"; 50 | } 51 | } 52 | 53 | export async function updateItemQuantity(payload: { 54 | lineId: string; 55 | variantId: string; 56 | quantity: number; 57 | }) { 58 | const cartId = Cookies.get("cartId"); 59 | 60 | if (!cartId) { 61 | return "Missing cart ID"; 62 | } 63 | 64 | const { lineId, variantId, quantity } = payload; 65 | 66 | try { 67 | if (quantity === 0) { 68 | await removeFromCart(cartId, [lineId]); 69 | // return (window.location.href = "/"); 70 | } 71 | 72 | await updateCart(cartId, [ 73 | { 74 | id: lineId, 75 | merchandiseId: variantId, 76 | quantity, 77 | }, 78 | ]); 79 | // return (window.location.href = "/"); 80 | } catch (e) { 81 | return "Error updating item quantity"; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/DeleteItemButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { FaXmark } from "react-icons/fa6"; 3 | import { removeItemFromCart, refreshCartState } from "@/cartStore"; 4 | import LoadingDots from "../loadings/LoadingDots"; 5 | 6 | interface SubmitButtonProps { 7 | onClick: () => void; 8 | pending: boolean; 9 | } 10 | 11 | const SubmitButton: React.FC = ({ onClick, pending }) => ( 12 | 26 | ); 27 | 28 | interface DeleteItemButtonProps { 29 | item: { 30 | id: string; 31 | }; 32 | } 33 | 34 | const DeleteItemButton: React.FC = ({ item }) => { 35 | const [pending, setPending] = useState(false); 36 | const [message, setMessage] = useState(""); 37 | 38 | const handleSubmit = async (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | setPending(true); 41 | 42 | try { 43 | await removeItemFromCart(item.id); 44 | await refreshCartState(); 45 | setMessage("Item removed"); 46 | } catch (error) { 47 | console.error("Error removing item:", error); 48 | setMessage("Error removing item"); 49 | } finally { 50 | setPending(false); 51 | } 52 | }; 53 | 54 | return ( 55 |
56 | !pending} pending={pending} /> 57 |

58 | {message} 59 |

60 | 61 | ); 62 | }; 63 | 64 | export default DeleteItemButton; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storeplate", 3 | "version": "2.5.0", 4 | "description": "Astro and Tailwindcss boilerplate", 5 | "author": "zeon.studio", 6 | "license": "MIT", 7 | "packageManager": "yarn@1.22.22", 8 | "type": "module", 9 | "scripts": { 10 | "dev": "concurrently \"node scripts/themeGenerator.js --watch\" \"astro dev\"", 11 | "build": "node scripts/themeGenerator.js && astro build", 12 | "preview": "astro preview", 13 | "format": "prettier -w ./src", 14 | "check": "astro check", 15 | "remove-darkmode": "node scripts/removeDarkmode.js && yarn format" 16 | }, 17 | "dependencies": { 18 | "@astrojs/check": "^0.9.6", 19 | "@astrojs/mdx": "4.3.12", 20 | "@astrojs/netlify": "6.6.3", 21 | "@astrojs/node": "^9.5.1", 22 | "@astrojs/react": "4.4.2", 23 | "@astrojs/sitemap": "^3.6.0", 24 | "@digi4care/astro-google-tagmanager": "^1.6.0", 25 | "@justinribeiro/lite-youtube": "^1.9.0", 26 | "@nanostores/react": "^1.0.0", 27 | "astro": "^5.16.3", 28 | "astro-auto-import": "^0.4.5", 29 | "astro-font": "^1.1.0", 30 | "date-fns": "^4.1.0", 31 | "github-slugger": "^2.0.0", 32 | "js-cookie": "^3.0.5", 33 | "marked": "^17.0.1", 34 | "nanostores": "^1.1.0", 35 | "prop-types": "^15.8.1", 36 | "react": "^19.2.0", 37 | "react-dom": "^19.2.0", 38 | "react-gravatar": "^2.6.3", 39 | "react-icons": "^5.5.0", 40 | "remark-collapse": "^0.1.2", 41 | "remark-toc": "^9.0.0", 42 | "sharp": "^0.34.5", 43 | "swiper": "^12.0.3", 44 | "vite": "^7.2.6" 45 | }, 46 | "devDependencies": { 47 | "@tailwindcss/forms": "^0.5.10", 48 | "@tailwindcss/typography": "^0.5.19", 49 | "@tailwindcss/vite": "^4.1.17", 50 | "@types/js-cookie": "^3.0.6", 51 | "@types/node": "24.10.1", 52 | "@types/react": "19.2.7", 53 | "@types/react-dom": "19.2.3", 54 | "@types/react-gravatar": "^2.6.14", 55 | "concurrently": "^9.2.1", 56 | "eslint": "^9.39.1", 57 | "prettier": "^3.7.3", 58 | "prettier-plugin-astro": "^0.14.1", 59 | "prettier-plugin-tailwindcss": "^0.7.1", 60 | "tailwind-bootstrap-grid": "^6.0.0", 61 | "tailwindcss": "^4.1.17", 62 | "typescript": "^5.9.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/layouts/partials/Footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Logo from "@/components/Logo.astro"; 3 | import config from "@/config/config.json"; 4 | import menu from "@/config/menu.json"; 5 | import social from "@/config/social.json"; 6 | import DynamicIcon from "@/helpers/DynamicIcon"; 7 | import { markdownify } from "@/lib/utils/textConverter"; 8 | const { copyright } = config.params; 9 | 10 | export interface ISocial { 11 | name: string; 12 | icon: string; 13 | link: string; 14 | } 15 | --- 16 | 17 |
18 |
19 |
22 | 23 | 24 |
    25 | { 26 | menu.footer.map((menu) => ( 27 | 30 | )) 31 | } 32 |
33 | 34 | {/* social share */} 35 | 52 |
53 | 54 |
55 |
58 |
    59 | { 60 | menu.footerCopyright.map((menu) => ( 61 | 64 | )) 65 | } 66 |
67 | 68 |

69 |

70 |
71 |
72 |
73 | -------------------------------------------------------------------------------- /src/cartStore.ts: -------------------------------------------------------------------------------- 1 | import { atom, computed } from "nanostores"; 2 | import Cookies from "js-cookie"; 3 | import { getCart } from "@/lib/shopify"; 4 | import { 5 | addItem, 6 | removeItem, 7 | updateItemQuantity, 8 | } from "@/lib/utils/cartActions"; 9 | import type { Cart } from "@/lib/shopify/types"; 10 | 11 | // Atom to hold the cart state 12 | export const cart = atom(null); 13 | 14 | // Computed store for total quantity in the cart 15 | export const totalQuantity = computed(cart, (c) => (c ? c.totalQuantity : 0)); 16 | 17 | // Atom to manage the layout view state (card or list) 18 | export const layoutView = atom<"card" | "list">("card"); 19 | 20 | // Function to set a new layout view 21 | export function setLayoutView(view: "card" | "list") { 22 | layoutView.set(view); 23 | } 24 | 25 | // Function to get the current layout view 26 | export function getLayoutView() { 27 | return layoutView.get(); 28 | } 29 | 30 | // Update cart state in the store 31 | export async function refreshCartState() { 32 | const cartId = Cookies.get("cartId"); 33 | if (cartId) { 34 | const currentCart = await getCart(cartId); 35 | cart.set(currentCart as any); 36 | } 37 | } 38 | 39 | // Add item to the cart and update state 40 | export async function addItemToCart(selectedVariantId: string) { 41 | try { 42 | await addItem(selectedVariantId); 43 | await refreshCartState(); 44 | return "Added to cart"; 45 | } catch (error: any) { 46 | throw new Error(error.message || "Failed to add to cart"); 47 | } 48 | } 49 | 50 | // Remove item from the cart and update state 51 | export async function removeItemFromCart(lineId: string) { 52 | try { 53 | await removeItem(lineId); 54 | await refreshCartState(); 55 | return "Removed from cart"; 56 | } catch (error: any) { 57 | throw new Error(error.message || "Failed to remove item from cart"); 58 | } 59 | } 60 | 61 | // Update item quantity in the cart and update state 62 | export async function updateCartItemQuantity(payload: { 63 | lineId: string; 64 | variantId: string; 65 | quantity: number; 66 | }) { 67 | try { 68 | await updateItemQuantity(payload); 69 | await refreshCartState(); 70 | return "Cart updated"; 71 | } catch (error: any) { 72 | throw new Error(error.message || "Failed to update cart"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/layouts/functional-components/HeroSlider.tsx: -------------------------------------------------------------------------------- 1 | import type { Product } from "@/lib/shopify/types"; 2 | import React from "react"; 3 | import "swiper/css"; 4 | import "swiper/css/pagination"; 5 | import { Pagination } from "swiper/modules"; 6 | import { Swiper, SwiperSlide } from "swiper/react"; 7 | 8 | const HeroSlider = ({ products }: { products: Product[] }) => { 9 | return ( 10 | <> 11 | 19 | {products?.map((item: Product) => ( 20 | 21 |
22 |
23 |
24 | {item?.description && ( 25 |

26 | {item.description} 27 |

28 | )} 29 |
30 |

31 | {item.title} 32 |

33 |
34 | {item.handle && ( 35 | 39 | Shop Now 40 | 41 | )} 42 |
43 |
44 | 45 |
46 | {item.featuredImage && ( 47 | banner image 54 | )} 55 |
56 |
57 |
58 | ))} 59 |
60 | 61 | ); 62 | }; 63 | 64 | export default HeroSlider; 65 | -------------------------------------------------------------------------------- /src/layouts/functional-components/filter/FilterDropdownItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { createUrl } from "@/lib/utils"; 3 | 4 | function PathFilterItem({ item }: { item: any }) { 5 | const [pathname, setPathname] = useState(""); 6 | const [searchParams, setSearchParams] = useState(new URLSearchParams()); 7 | 8 | useEffect(() => { 9 | setPathname(window.location.pathname); 10 | setSearchParams(new URLSearchParams(window.location.search)); 11 | }, []); 12 | 13 | const active = pathname === item.path; 14 | const newParams = new URLSearchParams(searchParams.toString()); 15 | const DynamicTag = active ? "p" : "a"; 16 | 17 | newParams.delete("q"); 18 | 19 | return ( 20 |
  • 21 | 26 | {item.title} 27 | 28 |
  • 29 | ); 30 | } 31 | 32 | function SortFilterItem({ item }: { item: any }) { 33 | const [pathname, setPathname] = useState(""); 34 | const [searchParams, setSearchParams] = useState(new URLSearchParams()); 35 | 36 | useEffect(() => { 37 | setPathname(window.location.pathname); 38 | setSearchParams(new URLSearchParams(window.location.search)); 39 | }, []); 40 | 41 | // const q = searchParams.get("q"); 42 | const newParams = new URLSearchParams(searchParams.toString()); 43 | 44 | if (item.slug) { 45 | newParams.set("sort", item.slug); 46 | } else { 47 | newParams.delete("sort"); 48 | } 49 | 50 | const href = createUrl(pathname, newParams); 51 | const active = searchParams.get("sort") === item.slug; 52 | const DynamicTag = active ? "p" : "a"; 53 | 54 | return ( 55 |
  • 59 | 63 | {item.title} 64 | 65 |
  • 66 | ); 67 | } 68 | 69 | export function FilterDropdownItem({ item }: { item: any }) { 70 | return "path" in item ? ( 71 | 72 | ) : ( 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/layouts/functional-components/product/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | const Tabs = ({ descriptionHtml }: { descriptionHtml: string }) => { 4 | const [description, setDescription] = useState(""); 5 | const [loading, setLoading] = useState(true); 6 | const [selectedTab, setSelectedTab] = useState(0); 7 | const contentArray = description.split(`--- split content ---`); 8 | 9 | useEffect(() => { 10 | setDescription(descriptionHtml); 11 | setLoading(false); 12 | }, [descriptionHtml]); 13 | 14 | if (loading) { 15 | return

    Loading...

    ; 16 | } 17 | 18 | return ( 19 | <> 20 |
    21 | 30 | {contentArray[1] && ( 31 | 40 | )} 41 |
    42 |
    43 | {selectedTab === 0 && ( 44 |
    48 | )} 49 | {selectedTab === 1 && contentArray[1] && ( 50 |
    54 | )} 55 |
    56 | 57 | ); 58 | }; 59 | 60 | export default Tabs; 61 | -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/EditItemQuantityButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { FaMinus, FaPlus } from "react-icons/fa6"; 3 | import { updateCartItemQuantity, refreshCartState } from "@/cartStore"; 4 | import type { CartItem } from "@/lib/shopify/types"; 5 | import LoadingDots from "../loadings/LoadingDots"; 6 | 7 | interface Props { 8 | item: CartItem; 9 | type: "plus" | "minus"; 10 | } 11 | 12 | const EditItemQuantityButton: React.FC = ({ item, type }) => { 13 | const [pending, setPending] = useState(false); 14 | const [message, setMessage] = useState(null); 15 | 16 | const handleSubmit = async (e: React.FormEvent) => { 17 | e.preventDefault(); 18 | 19 | const newQuantity = type === "plus" ? item.quantity + 1 : item.quantity - 1; 20 | if (newQuantity < 1) return; 21 | 22 | setPending(true); 23 | 24 | try { 25 | await updateCartItemQuantity({ 26 | lineId: item.id, 27 | variantId: item.merchandise.id, 28 | quantity: newQuantity, 29 | }); 30 | 31 | await refreshCartState(); 32 | setMessage("Quantity updated"); 33 | } catch (error) { 34 | console.error("Error updating item quantity:", error); 35 | setMessage("Failed to update quantity"); 36 | } finally { 37 | setPending(false); 38 | } 39 | }; 40 | 41 | return ( 42 |
    43 | 61 |

    62 | {message} 63 |

    64 |
    65 | ); 66 | }; 67 | 68 | export default EditItemQuantityButton; 69 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import React, { useEffect, useRef, useState } from "react"; 3 | 4 | interface TabProps { 5 | children: React.ReactElement<{ value: string }>; 6 | } 7 | 8 | const Tabs = ({ children }: TabProps) => { 9 | const [active, setActive] = useState(0); 10 | const [defaultFocus, setDefaultFocus] = useState(false); 11 | 12 | const tabRefs: React.RefObject = useRef([]); 13 | useEffect(() => { 14 | if (defaultFocus) { 15 | //@ts-ignore 16 | tabRefs.current[active]?.focus(); 17 | } else { 18 | setDefaultFocus(true); 19 | } 20 | }, [active]); 21 | 22 | const tabLinks = Array.from( 23 | children.props.value.matchAll( 24 | /]*>([\s\S]*?)<\/div>/g, 25 | ), 26 | (match: RegExpMatchArray) => ({ name: match[1], children: match[0] }), 27 | ); 28 | 29 | 30 | const handleKeyDown = ( 31 | event: React.KeyboardEvent, 32 | index: number, 33 | ) => { 34 | if (event.key === "Enter" || event.key === " ") { 35 | setActive(index); 36 | } else if (event.key === "ArrowRight") { 37 | setActive((active + 1) % tabLinks.length); 38 | } else if (event.key === "ArrowLeft") { 39 | setActive((active - 1 + tabLinks.length) % tabLinks.length); 40 | } 41 | }; 42 | 43 | return ( 44 |
    45 |
      46 | {tabLinks.map( 47 | (item: { name: string; children: string }, index: number) => ( 48 |
    • handleKeyDown(event, index)} 54 | onClick={() => setActive(index)} 55 | //@ts-ignore 56 | ref={(ref) => (tabRefs.current[index] = ref)} 57 | > 58 | {item.name} 59 |
    • 60 | ), 61 | )} 62 |
    63 | {tabLinks.map((item: { name: string; children: string }, i: number) => ( 64 |
    71 | ))} 72 |
    73 | ); 74 | }; 75 | 76 | export default Tabs; 77 | -------------------------------------------------------------------------------- /src/layouts/functional-components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { IoSearch, IoClose } from "react-icons/io5"; 3 | 4 | const SearchBar = () => { 5 | const [isInputEditing, setInputEditing] = useState(false); 6 | const [inputValue, setInputValue] = useState(""); 7 | 8 | useEffect(() => { 9 | const searchParams = new URLSearchParams(window.location.search); 10 | const query = searchParams.get("q"); 11 | if (query) { 12 | setInputValue(query); 13 | setInputEditing(true); 14 | } 15 | 16 | const inputField = document.getElementById("searchInput") as HTMLInputElement; 17 | if (isInputEditing || query) { 18 | inputField.focus(); 19 | } 20 | }, [isInputEditing]); 21 | 22 | const updateURL = (query: string) => { 23 | const newURL = query ? `/products?q=${encodeURIComponent(query)}` : '/products'; 24 | // window.history.pushState({}, '', newURL); 25 | window.location.href = newURL.toString(); 26 | }; 27 | 28 | const handleChange = (e: React.ChangeEvent) => { 29 | setInputEditing(true); 30 | setInputValue(e.target.value); 31 | 32 | updateURL(e.target.value); 33 | }; 34 | 35 | const handleClear = () => { 36 | setInputValue(""); 37 | setInputEditing(false); 38 | updateURL(""); 39 | }; 40 | 41 | const onSubmit = (e: React.FormEvent) => { 42 | e.preventDefault(); 43 | const form = e.target as HTMLFormElement; 44 | const searchInput = form.search as HTMLInputElement; 45 | updateURL(searchInput.value); 46 | }; 47 | 48 | return ( 49 |
    50 | 60 |
    61 | {inputValue && ( 62 | 69 | )} 70 | 73 |
    74 |
    75 | ); 76 | }; 77 | 78 | export default SearchBar; 79 | -------------------------------------------------------------------------------- /src/layouts/functional-components/loadings/skeleton/SkeletonProductGallery.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | const SkeletonProductGallery = () => { 3 | return ( 4 | <> 5 |
    6 |
    7 |
    8 | {/* right side contents */} 9 |
    10 |
    11 |
    12 | {Array(4) 13 | .fill(0) 14 | .map((_, index) => { 15 | return ( 16 |
    20 | ); 21 | })} 22 |
    23 |
    24 | 25 | {/* left side contents */} 26 |
    27 | {Array(8) 28 | .fill(0) 29 | .map((_, index) => { 30 | return ( 31 |
    35 | ); 36 | })} 37 |
    38 |
    39 |
    40 |
    41 | 42 |
    43 |
    44 |
    45 | {Array(9) 46 | .fill(0) 47 | .map((_, index) => { 48 | return ( 49 |
    50 |
    51 |
    52 |
    53 |
    54 |
    55 |
    56 | ); 57 | })} 58 |
    59 |
    60 |
    61 | 62 | ); 63 | }; 64 | 65 | export default SkeletonProductGallery; 66 | -------------------------------------------------------------------------------- /src/layouts/helpers/Announcement.tsx: -------------------------------------------------------------------------------- 1 | import config from "@/config/config.json"; 2 | import { markdownify } from "@/lib/utils/textConverter"; 3 | import React, { useEffect, useState } from "react"; 4 | 5 | const { enable, content, expire_days } = config.announcement; 6 | 7 | const Cookies = { 8 | set: (name: string, value: string, options: any = {}) => { 9 | if (typeof document === "undefined") return; 10 | 11 | const defaults = { path: "/" }; 12 | const opts = { ...defaults, ...options }; 13 | 14 | if (typeof opts.expires === "number") { 15 | opts.expires = new Date(Date.now() + opts.expires * 864e5); 16 | } 17 | if (opts.expires instanceof Date) { 18 | opts.expires = opts.expires.toUTCString(); 19 | } 20 | 21 | let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; 22 | 23 | for (let key in opts) { 24 | if (!opts[key]) continue; 25 | cookieString += `; ${key}`; 26 | if (opts[key] !== true) { 27 | cookieString += `=${opts[key]}`; 28 | } 29 | } 30 | 31 | document.cookie = cookieString; 32 | }, 33 | 34 | get: (name: string): string | null => { 35 | if (typeof document === "undefined") return null; 36 | 37 | const cookies = document.cookie.split("; "); 38 | for (let cookie of cookies) { 39 | const [key, value] = cookie.split("="); 40 | if (decodeURIComponent(key) === name) { 41 | return decodeURIComponent(value); 42 | } 43 | } 44 | return null; 45 | }, 46 | 47 | remove: (name: string, options: any = {}) => { 48 | Cookies.set(name, "", { ...options, expires: -1 }); 49 | }, 50 | }; 51 | 52 | const Announcement: React.FC = () => { 53 | const [isVisible, setIsVisible] = useState(false); 54 | 55 | useEffect(() => { 56 | if (enable && content && !Cookies.get("announcement-close")) { 57 | setIsVisible(true); 58 | } 59 | }, []); 60 | 61 | const handleClose = () => { 62 | Cookies.set("announcement-close", "true", { 63 | expires: expire_days, 64 | }); 65 | setIsVisible(false); 66 | }; 67 | 68 | if (!enable || !content || !isVisible) { 69 | return null; 70 | } 71 | 72 | return ( 73 |
    74 |

    77 | 84 |

    85 | ); 86 | }; 87 | 88 | export default Announcement; 89 | -------------------------------------------------------------------------------- /src/layouts/functional-components/SocialShare.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import DynamicIcon from "@/helpers/DynamicIcon"; 3 | 4 | const SocialShare: React.FC<{ socialName: string; className: string; pathname: string }> = ({ 5 | socialName, 6 | className, 7 | pathname, 8 | }) => { 9 | const [baseUrl, setBaseUrl] = useState(""); 10 | const [isTooltipVisible, setIsTooltipVisible] = useState(false); 11 | 12 | useEffect(() => { 13 | setBaseUrl(window.location.origin); 14 | }, []); 15 | 16 | const handleCopyLink = async () => { 17 | const fullLink = `${baseUrl}${window.location.pathname}`; 18 | 19 | try { 20 | await navigator.clipboard.writeText(fullLink); 21 | // Show the tooltip 22 | setIsTooltipVisible(true); 23 | setTimeout(() => { 24 | setIsTooltipVisible(false); 25 | }, 1000); 26 | } catch (error) { 27 | console.error("Failed to copy text: ", error); 28 | } 29 | }; 30 | 31 | return ( 32 | 89 | ); 90 | }; 91 | 92 | export default SocialShare; 93 | -------------------------------------------------------------------------------- /src/layouts/functional-components/filter/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import type { ListItem } from "../product/ProductLayouts"; 3 | import { FilterDropdownItem } from "./FilterDropdownItem"; 4 | 5 | 6 | const DropdownMenu = ({ list }: { list: ListItem[] }) => { 7 | const [active, setActive] = useState(""); 8 | const [openSelect, setOpenSelect] = useState(false); 9 | const menuRef = useRef(null); 10 | 11 | useEffect(() => { 12 | const handleClickOutside = (event: MouseEvent) => { 13 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 14 | setOpenSelect(false); 15 | } 16 | }; 17 | 18 | window.addEventListener("click", handleClickOutside); 19 | return () => window.removeEventListener("click", handleClickOutside); 20 | }, []); 21 | 22 | useEffect(() => { 23 | const currentPath = window.location.pathname; 24 | const searchParams = new URLSearchParams(window.location.search); 25 | 26 | list.forEach((listItem) => { 27 | if ( 28 | ("path" in listItem && currentPath === listItem.path) || 29 | ("slug" in listItem && searchParams.get("sort") === listItem.slug) 30 | ) { 31 | setActive(listItem.title); 32 | } 33 | }); 34 | }, [list]); 35 | 36 | return ( 37 |
    38 | 62 | 63 | {openSelect && ( 64 |
    { 67 | setOpenSelect(false); 68 | }} 69 | > 70 |
    75 | {list.map((item, i) => ( 76 | 77 | ))} 78 |
    79 |
    80 | )} 81 |
    82 | ); 83 | }; 84 | 85 | export default DropdownMenu; 86 | -------------------------------------------------------------------------------- /src/layouts/components/FeaturedProducts.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "@/config/config.json"; 3 | import { AddToCart } from "@/functional-components/cart/AddToCart"; 4 | import type { Product } from "@/lib/shopify/types"; 5 | 6 | interface Props { 7 | products: Product[]; 8 | } 9 | 10 | const { products } = Astro.props; 11 | const { currencySymbol } = config.shopify; 12 | --- 13 | 14 |
    15 | { 16 | products.map((product: any) => { 17 | const { 18 | title, 19 | handle, 20 | featuredImage, 21 | priceRange, 22 | variants, 23 | compareAtPriceRange, 24 | } = product; 25 | 26 | const defaultVariantId = variants.length > 0 ? variants[0].id : undefined; 27 | return ( 28 |
    29 |
    30 | {featuredImage.altText 37 | 38 | 46 |
    47 |
    48 |

    49 | 53 | {title} 54 | 55 |

    56 |
    57 | 58 | {currencySymbol} 59 | {priceRange.minVariantPrice.amount} 60 | {compareAtPriceRange?.maxVariantPrice?.currencyCode} 61 | 62 | 63 | {parseFloat(compareAtPriceRange?.maxVariantPrice.amount) > 0 && ( 64 | 65 | {currencySymbol} {compareAtPriceRange?.maxVariantPrice.amount}{" "} 66 | {compareAtPriceRange?.maxVariantPrice?.currencyCode} 67 | 68 | )} 69 |
    70 |
    71 |
    72 | ); 73 | }) 74 | } 75 |
    76 | 77 | 82 | -------------------------------------------------------------------------------- /scripts/removeDarkmode.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | (function () { 5 | const rootDirs = ["src/pages", "src/hooks", "src/layouts", "src/styles"]; 6 | 7 | const deleteAssetList = [ 8 | "public/images/logo-darkmode.png", 9 | "src/layouts/components/ThemeSwitcher.astro", 10 | ]; 11 | 12 | const configFiles = [ 13 | { 14 | filePath: "tailwind.config.js", 15 | patterns: ["darkmode:\\s*{[^}]*},", 'darkMode:\\s*"class",'], 16 | }, 17 | { filePath: "src/config/theme.json", patterns: ["colors.darkmode"] }, 18 | ]; 19 | 20 | const filePaths = [ 21 | { 22 | filePath: "src/layouts/partials/Header.astro", 23 | patterns: [ 24 | "]+)?\\s*(?:\\/\\>|>([\\s\\S]*?)<\\/ThemeSwitchers*>)", 25 | ], 26 | }, 27 | ]; 28 | 29 | filePaths.forEach(({ filePath, patterns }) => { 30 | removeDarkModeFromFiles(filePath, patterns); 31 | }); 32 | 33 | deleteAssetList.forEach(deleteAsset); 34 | function deleteAsset(asset) { 35 | try { 36 | fs.unlinkSync(asset); 37 | console.log(`${path.basename(asset)} deleted successfully!`); 38 | } catch (error) { 39 | console.error(`${asset} not found`); 40 | } 41 | } 42 | 43 | rootDirs.forEach(removeDarkModeFromPages); 44 | configFiles.forEach(removeDarkMode); 45 | 46 | function removeDarkModeFromFiles(filePath, regexPatterns) { 47 | const fileContent = fs.readFileSync(filePath, "utf8"); 48 | let updatedContent = fileContent; 49 | regexPatterns.forEach((pattern) => { 50 | const regex = new RegExp(pattern, "g"); 51 | updatedContent = updatedContent.replace(regex, ""); 52 | }); 53 | fs.writeFileSync(filePath, updatedContent, "utf8"); 54 | } 55 | 56 | function removeDarkModeFromPages(directoryPath) { 57 | const files = fs.readdirSync(directoryPath); 58 | 59 | files.forEach((file) => { 60 | const filePath = path.join(directoryPath, file); 61 | const stats = fs.statSync(filePath); 62 | if (stats.isDirectory()) { 63 | removeDarkModeFromPages(filePath); 64 | } else if (stats.isFile()) { 65 | removeDarkModeFromFiles(filePath, [ 66 | '(?:(?!["])\\S)*dark:(?:(?![,;"])\\S)*', 67 | ]); 68 | } 69 | }); 70 | } 71 | 72 | function removeDarkMode(configFile) { 73 | const { filePath, patterns } = configFile; 74 | if (filePath === "tailwind.config.js") { 75 | removeDarkModeFromFiles(filePath, patterns); 76 | } else { 77 | const contentFile = JSON.parse(fs.readFileSync(filePath, "utf8")); 78 | patterns.forEach((pattern) => deleteNestedProperty(contentFile, pattern)); 79 | fs.writeFileSync(filePath, JSON.stringify(contentFile)); 80 | } 81 | } 82 | 83 | function deleteNestedProperty(obj, propertyPath) { 84 | const properties = propertyPath.split("."); 85 | let currentObj = obj; 86 | for (let i = 0; i < properties.length - 1; i++) { 87 | const property = properties[i]; 88 | if (currentObj.hasOwnProperty(property)) { 89 | currentObj = currentObj[property]; 90 | } else { 91 | return; // Property not found, no need to continue 92 | } 93 | } 94 | delete currentObj[properties[properties.length - 1]]; 95 | } 96 | })(); 97 | -------------------------------------------------------------------------------- /src/layouts/functional-components/product/VariantDropDown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | 3 | const VariantDropDown = ({ sizeOption }: any) => { 4 | const [isOpen, setIsOpen] = useState(false); 5 | const [selected, setSelected] = useState("Select One"); 6 | const dropdownRef = useRef(null); 7 | 8 | const updateUrl = (param: string, value: string) => { 9 | const searchParams = new URLSearchParams(window.location.search); 10 | searchParams.set(param.toLowerCase(), value); 11 | const newUrl = `${window.location.pathname}?${searchParams.toString()}`; 12 | 13 | // Replace the URL without reloading the page 14 | window.history.replaceState({}, "", newUrl); 15 | }; 16 | 17 | const handleSizeChanged = (value: string) => { 18 | setSelected(value); 19 | updateUrl(sizeOption.name, value); 20 | setIsOpen(false); 21 | }; 22 | 23 | useEffect(() => { 24 | const setInitialSizeFromUrl = () => { 25 | const searchParams = new URLSearchParams(window.location.search); 26 | const sizeParam = searchParams.get(sizeOption.name.toLowerCase()); 27 | if (sizeParam && sizeOption.values.includes(sizeParam)) { 28 | setSelected(sizeParam); 29 | } 30 | }; 31 | 32 | setInitialSizeFromUrl(); 33 | 34 | const handleClickOutside = (event: MouseEvent) => { 35 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 36 | setIsOpen(false); 37 | } 38 | }; 39 | 40 | document.addEventListener("mousedown", handleClickOutside); 41 | return () => document.removeEventListener("mousedown", handleClickOutside); 42 | }, [sizeOption]); 43 | 44 | return ( 45 |
    46 | 66 | 67 | {isOpen && ( 68 |
      69 | {sizeOption?.values?.map((size: string) => ( 70 |
    • handleSizeChanged(size)} 74 | > 75 | {size} 76 |
    • 77 | ))} 78 |
    79 | )} 80 |
    81 | ); 82 | }; 83 | 84 | export default VariantDropDown; 85 | -------------------------------------------------------------------------------- /src/layouts/functional-components/NavUser.tsx: -------------------------------------------------------------------------------- 1 | import { getUserDetails } from "@/lib/shopify"; 2 | import type { user } from "@/lib/shopify/types"; 3 | import Cookies from "js-cookie"; 4 | import React, { useEffect, useState } from "react"; 5 | import Gravatar from "react-gravatar"; 6 | import { BsPerson } from "react-icons/bs"; 7 | 8 | export const fetchUser = async () => { 9 | try { 10 | const accessToken = Cookies.get("token"); 11 | 12 | if (!accessToken) { 13 | return null; 14 | } else { 15 | const userDetails: user = await getUserDetails(accessToken); 16 | const userInfo = userDetails.customer; 17 | return userInfo; 18 | } 19 | } catch (error) { 20 | // console.log("Error fetching user details:", error); 21 | return null; 22 | } 23 | }; 24 | 25 | const NavUser = ({ pathname }: { pathname: string }) => { 26 | const [user, setUser] = useState(); 27 | const [dropdownOpen, setDropdownOpen] = useState(false); 28 | 29 | useEffect(() => { 30 | const getUser = async () => { 31 | const userInfo = await fetchUser(); 32 | setUser(userInfo); 33 | }; 34 | getUser(); 35 | }, [pathname]); 36 | 37 | const handleLogout = () => { 38 | Cookies.remove("token"); 39 | localStorage.removeItem("user"); 40 | setUser(null); 41 | }; 42 | 43 | const toggleDropdown = () => { 44 | setDropdownOpen(!dropdownOpen); 45 | }; 46 | 47 | return ( 48 |
    49 | {user ? ( 50 | 82 | ) : ( 83 | 88 | 89 | 90 | )} 91 | 92 | {dropdownOpen && ( 93 |
    94 | 97 |
    98 | )} 99 |
    100 | ); 101 | }; 102 | 103 | export default NavUser; 104 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | ##### Optimize default expiration time - BEGIN 2 | 3 | 4 | ## Enable expiration control 5 | ExpiresActive On 6 | 7 | ## CSS and JS expiration: 1 week after request 8 | ExpiresByType text/css "now plus 1 week" 9 | ExpiresByType application/javascript "now plus 1 week" 10 | ExpiresByType application/x-javascript "now plus 1 week" 11 | 12 | ## Image files expiration: 1 month after request 13 | ExpiresByType image/bmp "now plus 1 month" 14 | ExpiresByType image/gif "now plus 1 month" 15 | ExpiresByType image/jpeg "now plus 1 month" 16 | ExpiresByType image/webp "now plus 1 month" 17 | ExpiresByType image/jp2 "now plus 1 month" 18 | ExpiresByType image/pipeg "now plus 1 month" 19 | ExpiresByType image/png "now plus 1 month" 20 | ExpiresByType image/svg+xml "now plus 1 month" 21 | ExpiresByType image/tiff "now plus 1 month" 22 | ExpiresByType image/x-icon "now plus 1 month" 23 | ExpiresByType image/ico "now plus 1 month" 24 | ExpiresByType image/icon "now plus 1 month" 25 | ExpiresByType text/ico "now plus 1 month" 26 | ExpiresByType application/ico "now plus 1 month" 27 | ExpiresByType image/vnd.wap.wbmp "now plus 1 month" 28 | 29 | ## Font files expiration: 1 month after request 30 | ExpiresByType application/x-font-ttf "now plus 1 month" 31 | ExpiresByType application/x-font-opentype "now plus 1 month" 32 | ExpiresByType application/x-font-woff "now plus 1 month" 33 | ExpiresByType font/woff2 "now plus 1 month" 34 | ExpiresByType image/svg+xml "now plus 1 month" 35 | 36 | ## Audio files expiration: 1 month after request 37 | ExpiresByType audio/ogg "now plus 1 month" 38 | ExpiresByType application/ogg "now plus 1 month" 39 | ExpiresByType audio/basic "now plus 1 month" 40 | ExpiresByType audio/mid "now plus 1 month" 41 | ExpiresByType audio/midi "now plus 1 month" 42 | ExpiresByType audio/mpeg "now plus 1 month" 43 | ExpiresByType audio/mp3 "now plus 1 month" 44 | ExpiresByType audio/x-aiff "now plus 1 month" 45 | ExpiresByType audio/x-mpegurl "now plus 1 month" 46 | ExpiresByType audio/x-pn-realaudio "now plus 1 month" 47 | ExpiresByType audio/x-wav "now plus 1 month" 48 | 49 | ## Movie files expiration: 1 month after request 50 | ExpiresByType application/x-shockwave-flash "now plus 1 month" 51 | ExpiresByType x-world/x-vrml "now plus 1 month" 52 | ExpiresByType video/x-msvideo "now plus 1 month" 53 | ExpiresByType video/mpeg "now plus 1 month" 54 | ExpiresByType video/mp4 "now plus 1 month" 55 | ExpiresByType video/quicktime "now plus 1 month" 56 | ExpiresByType video/x-la-asf "now plus 1 month" 57 | ExpiresByType video/x-ms-asf "now plus 1 month" 58 | 59 | ##### Optimize default expiration time - END 60 | 61 | ##### 1 Month for most static resources 62 | 63 | Header set Cache-Control "max-age=2592000, public" 64 | 65 | 66 | ##### Enable gzip compression for resources 67 | 68 | mod_gzip_on Yes 69 | mod_gzip_dechunk Yes 70 | mod_gzip_item_include file .(html?|txt|css|js|php)$ 71 | mod_gzip_item_include handler ^cgi-script$ 72 | mod_gzip_item_include mime ^text/.* 73 | mod_gzip_item_include mime ^application/x-javascript.* 74 | mod_gzip_item_exclude mime ^image/.* 75 | mod_gzip_item_exclude rspheader ^Content-Encoding:.*gzip.* 76 | 77 | 78 | ##### Or, compress certain file types by extension: 79 | 80 | SetOutputFilter DEFLATE 81 | 82 | 83 | ##### Set Header Vary: Accept-Encoding 84 | 85 | 86 | Header append Vary: Accept-Encoding 87 | 88 | -------------------------------------------------------------------------------- /src/layouts/components/ThemeSwitcher.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "@/config/config.json"; 3 | 4 | const { theme_switcher }: { theme_switcher: boolean } = config.settings; 5 | const { className }: { className?: string } = Astro.props; 6 | --- 7 | 8 | { 9 | theme_switcher && ( 10 |
    11 | 12 | 40 |
    41 | ) 42 | } 43 | 44 | 89 | -------------------------------------------------------------------------------- /src/layouts/functional-components/CollectionsSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { 3 | HiOutlineArrowNarrowLeft, 4 | HiOutlineArrowNarrowRight, 5 | } from "react-icons/hi"; 6 | import "swiper/css"; 7 | import "swiper/css/navigation"; 8 | import "swiper/css/pagination"; 9 | import { Navigation, Pagination } from "swiper/modules"; 10 | import { Swiper, SwiperSlide } from "swiper/react"; 11 | import SkeletonCategory from "./loadings/skeleton/SkeletonCategory"; 12 | 13 | const CollectionsSlider = ({ collections }: { collections: any }) => { 14 | const [_, setInit] = useState(false); 15 | const [isHovered, setIsHovered] = useState(false); 16 | const [collectionsData, setCollectionsData] = useState([]); 17 | const [loadingCollectionsData, setLoadingCollectionsData] = useState(true); 18 | 19 | const prevRef = useRef(null); 20 | const nextRef = useRef(null); 21 | 22 | useEffect(() => { 23 | setCollectionsData(collections); 24 | setLoadingCollectionsData(false); 25 | }, [collections]); 26 | 27 | if (loadingCollectionsData) { 28 | return ; 29 | } 30 | 31 | return ( 32 |
    setIsHovered(true)} 35 | onMouseLeave={() => setIsHovered(false)} 36 | > 37 | setInit(true)} 62 | > 63 | {collectionsData?.map((item: any) => { 64 | const { title, handle, image, } = item; 65 | return ( 66 | 67 |
    68 | {title} 75 |
    76 |

    77 | 81 | {title} 82 | 83 |

    84 |

    85 | {item.products?.edges.length} items 86 |

    87 |
    88 |
    89 |
    90 | ); 91 | })} 92 | 93 |
    99 |
    103 | 104 |
    105 |
    109 | 110 |
    111 |
    112 |
    113 |
    114 | ); 115 | }; 116 | 117 | export default CollectionsSlider; 118 | -------------------------------------------------------------------------------- /src/layouts/shortcodes/Notice.tsx: -------------------------------------------------------------------------------- 1 | import { humanize } from "@/lib/utils/textConverter"; 2 | import React from "react"; 3 | 4 | function Notice({ 5 | type, 6 | children, 7 | }: { 8 | type: string; 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 |
    13 |
    14 | {type === "tip" ? ( 15 | 22 | 28 | 29 | ) : type === "info" ? ( 30 | 37 | 41 | 45 | 46 | ) : type === "warning" ? ( 47 | 54 | 60 | 61 | ) : ( 62 | 69 | 76 | 77 | )} 78 |

    {humanize(type)}

    79 |
    80 |
    {children}
    81 |
    82 | ); 83 | } 84 | 85 | export default Notice; 86 | -------------------------------------------------------------------------------- /src/pages/contact.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "@/config/config.json"; 3 | import Base from "@/layouts/Base.astro"; 4 | import { getListPage } from "@/lib/contentParser.astro"; 5 | import { markdownify } from "@/lib/utils/textConverter"; 6 | import PageHeader from "@/partials/PageHeader.astro"; 7 | 8 | const contact = await getListPage("contact", "-index"); 9 | 10 | if (contact.data.draft) { 11 | return Astro.redirect("/404"); 12 | } 13 | 14 | const { contact_form_action }: { contact_form_action: string } = config.params; 15 | const { title, meta_title, description, image, contact_meta } = contact.data; 16 | --- 17 | 18 | 24 | 25 | 26 |
    27 |
    28 |
    29 | { 30 | contact_meta && 31 | contact_meta?.map((contact) => ( 32 |
    33 |

    37 |

    38 |

    39 | )) 40 | } 41 |
    42 |
    43 |
    44 | 45 |
    46 |
    47 |
    48 |

    We would love to hear from you!

    49 | 50 |
    55 |
    56 |
    57 | 60 | 68 |
    69 | 70 |
    71 | 72 | 79 |
    80 |
    81 | 82 |
    83 |
    84 | 87 | 95 |
    96 | 97 |
    98 | 101 | 109 |
    110 |
    111 | 112 |
    113 | 116 | 123 |
    124 | 125 |
    126 | 129 |
    130 |
    131 |
    132 |
    133 |
    134 | 135 | -------------------------------------------------------------------------------- /src/layouts/components/Pagination.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Pagination = { 3 | section?: string; 4 | currentPage?: number; 5 | totalPages?: number; 6 | }; 7 | const { section, currentPage = 1, totalPages = 1 }: Pagination = Astro.props; 8 | 9 | const indexPageLink = currentPage === 2; 10 | const hasPrevPage = currentPage > 1; 11 | const hasNextPage = totalPages > currentPage!; 12 | 13 | let pageList: number[] = []; 14 | for (let i = 1; i <= totalPages; i++) { 15 | pageList.push(i); 16 | } 17 | --- 18 | 19 | { 20 | totalPages > 1 && ( 21 | 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /src/content/pages/privacy-policy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Privacy Policy" 3 | meta_title: "" 4 | description: "this is meta description" 5 | image: "" 6 | draft: false 7 | --- 8 | 9 | ## This Privacy policy was published on 04 May 2023 10 | 11 | ### GDPR Compliance 12 | 13 | We collect certain identifying personal data when you sign up to our Service such as your name, email address, PayPal address (if different from email address), and telephone number. The personal data we collect from you is disclosed only in accordance with our Terms of Service and/or this Privacy Policy.Conclude collects Slack account and access information from Users for the purposes of connecting to the Slack API and to authenticate access to information on the Conclude website. Whenever you visit our Site, we may 14 | 15 | collect non-identifying information from you, such as referring URL, browser, operating system, cookie information, and Internet Service Provider. Without a subpoena, voluntary compliance on the part of your Internet Service Provider, or additional records from a third party, this information alone cannot usually be used to identify you.The term "personal data" does not include any anonymized and aggregated data made on the basis of personal data, which are wholly owned by Conclude. 16 |
    17 | 18 | ### About Storeplate 19 | 20 | #### Service Provided As 21 | 22 | The discovery was made by Richard McClintock , a professor of Latin at Hampden-Sydney College in Virginia, who faced the impetuous recurrence of the dark word consectetur in the text Lorem ipsum researched its origins to identify them in sections 1.10.32 and 1.10.33 of the aforementioned Cicero's 23 | 24 | When referring to Lorem ipsum, different expressions are used, namely fill text , fictitious text , blind text or placeholder text : in short, its meaning can also be zero, but its usefulness is so clear as to go 25 |
    26 | 27 | #### Company Liability 28 | 29 | The choice of font and font size with which Lorem ipsum is reproduced answers to specific needs that go beyond the simple and simple filling of spaces dedicated to accepting real texts and allowing to have hands an advertising/publishing product, both web and paper, true to reality. 30 | 31 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa, quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt, explicabo. Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores 32 |
    33 | 34 | #### When we collect personal data about you 35 | 36 | In order to use our Service, you must meet a number of conditions, including but not limited to: 37 | 38 | - Enhance or improve User experience, our Site, or our Service. 39 | - Send emails and updates about Conclude, Process transactions. 40 | - Send emails about our Site or respond to inquiries. 41 | - Including news and requests for agreement to amended legal documents such as this 42 | Privacy Policy and our Terms of Service. 43 |
    44 | 45 | #### Why we collect and use personal data 46 | 47 | Users of Conclude (i) must keep passwords secure and confidential; (ii) are solely responsible for User Data and all activity in their account while using the Service; (iii) must use commercially reasonable efforts access to their account, and notify Conclude promptly 48 | 49 | - Enhance or improve User experience, our Site, or our Service. 50 | - Send emails and updates about Conclude, Process transactions. 51 | - Send emails about our Site or respond to inquiries. 52 | - Including news and requests for agreement to amended legal documents such as this 53 | Privacy Policy and our Terms of Service. 54 |
    55 | 56 | #### Type of personal data collected 57 | 58 | Your information may be transferred to — and maintained on — computers located outside of your state, province, country or other governmental jurisdiction where the privacy laws may not be as protective as those in your jurisdiction. If you choose to provide information to us, Conclude transfers Personal Information to Google Cloud Platform and processes it there. Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer. 59 | 60 | Your information may be transferred to — and maintained on — computers located outside of your state, province, country or other governmental jurisdiction where the privacy laws may not be as protective as those in your jurisdiction. If you choose to provide information to us, Conclude transfers Personal Information to Google Cloud Platform and processes it there. Your consent to this Privacy Policy followed by your submission of such information represents your agreement to that transfer. 61 | -------------------------------------------------------------------------------- /src/layouts/partials/Testimonials.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | import { markdownify } from "@/lib/utils/textConverter"; 4 | import type { Testimonial } from "@/types"; 5 | 6 | interface Props { 7 | title: string; 8 | testimonials: Array; 9 | } 10 | 11 | const { title, testimonials } = Astro.props; 12 | --- 13 | 14 |
    15 |
    16 |
    17 |
    18 |

    19 |

    20 |
    21 |
    22 |
    23 | { 24 | testimonials.map((item: Testimonial) => ( 25 |
    26 |
    27 |
    28 | 35 | 39 | 43 | 44 |
    45 |
    49 |
    50 |
    51 | {item.name} 58 |
    59 |

    63 |

    67 |

    68 |
    69 |
    70 | )) 71 | } 72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    78 |
    79 |
    80 | 81 | 92 | 93 | 112 | -------------------------------------------------------------------------------- /src/pages/login.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Base from "@/layouts/Base.astro"; 3 | import { BiLoaderAlt } from "react-icons/bi"; 4 | --- 5 | 6 | 7 |
    8 |
    9 |
    10 |
    11 |
    12 |

    Login

    13 |

    14 | Please fill your email and password to login 15 |

    16 |
    17 | 18 |
    19 |
    20 | 21 | 28 |
    29 | 30 |
    31 | 32 | 39 |
    40 | 41 |
    42 | 43 | 54 |
    55 | 56 |
    57 |

    58 | Don't have an account? 59 |

    60 | 64 | Register 65 | 66 |
    67 |
    68 |
    69 |
    70 |
    71 | 72 | 147 | 148 | -------------------------------------------------------------------------------- /src/layouts/functional-components/rangeSlider/RangeSlider.tsx: -------------------------------------------------------------------------------- 1 | import config from "@/config/config.json"; 2 | import React, { useEffect, useRef, useState } from "react"; 3 | import "./rangeSlider.css"; 4 | 5 | function createUrl(path: string, params: URLSearchParams) { 6 | return `${path}?${params.toString()}`; 7 | } 8 | 9 | const RangeSlider = ({ 10 | maxPriceData, 11 | }: { 12 | maxPriceData: { amount: string; currencyCode: string }; 13 | }) => { 14 | const { currencyCode, currencySymbol } = config.shopify; 15 | 16 | const maxAmount = parseInt(maxPriceData?.amount); 17 | const [minValue, setMinValue] = useState(0); 18 | const [maxValue, setMaxValue] = useState(maxAmount); 19 | 20 | const rangeRef = useRef(null); 21 | const minThumbRef = useRef(null); 22 | const maxThumbRef = useRef(null); 23 | const rangeLineRef = useRef(null); 24 | 25 | const searchParams = new URLSearchParams(window.location.search); 26 | const getMinPrice = searchParams.get("minPrice"); 27 | const getMaxPrice = searchParams.get("maxPrice"); 28 | 29 | // Initialize from URL 30 | useEffect(() => { 31 | setMinValue(parseInt(getMinPrice || "0")); 32 | setMaxValue(parseInt(getMaxPrice || maxPriceData?.amount)); 33 | }, [maxPriceData]); 34 | 35 | useEffect(() => { 36 | updateRangeBar(); 37 | }, [minValue, maxValue]); 38 | 39 | const updateRangeBar = () => { 40 | if ( 41 | !rangeLineRef.current || 42 | !minThumbRef.current || 43 | !maxThumbRef.current || 44 | !rangeRef.current 45 | ) 46 | return; 47 | 48 | const minPercent = (minValue / maxAmount) * 100; 49 | const maxPercent = (maxValue / maxAmount) * 100; 50 | 51 | rangeLineRef.current.style.left = `${minPercent}%`; 52 | rangeLineRef.current.style.width = `${maxPercent - minPercent}%`; 53 | 54 | minThumbRef.current.style.left = `${minPercent}%`; 55 | maxThumbRef.current.style.left = `${maxPercent}%`; 56 | }; 57 | 58 | const handleMouseDown = (thumb: "min" | "max") => (e: React.MouseEvent) => { 59 | e.preventDefault(); 60 | 61 | const startX = e.clientX; 62 | const rangeRect = rangeRef.current?.getBoundingClientRect(); 63 | if (!rangeRect) return; 64 | 65 | const rangeWidth = rangeRect.width; 66 | const initialMinVal = minValue; 67 | const initialMaxVal = maxValue; 68 | 69 | const handleMouseMove = (e: MouseEvent) => { 70 | const dx = e.clientX - startX; 71 | const dPercent = (dx / rangeWidth) * 100; 72 | 73 | if (thumb === "min") { 74 | const newPercent = (initialMinVal / maxAmount) * 100 + dPercent; 75 | const newValue = Math.max( 76 | 0, 77 | Math.min(maxValue - 1, Math.round((newPercent * maxAmount) / 100)) 78 | ); 79 | setMinValue(newValue); 80 | } else { 81 | const newPercent = (initialMaxVal / maxAmount) * 100 + dPercent; 82 | const newValue = Math.max( 83 | minValue + 1, 84 | Math.min(maxAmount, Math.round((newPercent * maxAmount) / 100)) 85 | ); 86 | setMaxValue(newValue); 87 | } 88 | }; 89 | 90 | const handleMouseUp = () => { 91 | document.removeEventListener("mousemove", handleMouseMove); 92 | document.removeEventListener("mouseup", handleMouseUp); 93 | }; 94 | 95 | document.addEventListener("mousemove", handleMouseMove); 96 | document.addEventListener("mouseup", handleMouseUp); 97 | }; 98 | 99 | function priceChange(min: number, max: number) { 100 | const searchParams = new URLSearchParams(window.location.search); 101 | searchParams.set("minPrice", min.toString()); 102 | searchParams.set("maxPrice", max.toString()); 103 | 104 | const newUrl = createUrl("/products", searchParams); 105 | window.location.href = newUrl; 106 | } 107 | 108 | const showSubmitButton = 109 | (minValue !== (getMinPrice ? parseInt(getMinPrice) : 0) || 110 | maxValue !== (getMaxPrice ? parseInt(getMaxPrice) : maxAmount)) && 111 | (minValue !== 0 || maxValue !== maxAmount); 112 | 113 | return ( 114 |
    115 |
    116 |

    117 | {currencySymbol} 118 | {minValue} {maxPriceData?.currencyCode || currencyCode} 119 |

    120 |

    121 | {currencySymbol} 122 | {maxValue} {maxPriceData?.currencyCode || currencyCode} 123 |

    124 |
    125 | 126 |
    127 |
    128 |
    129 |
    134 |
    139 |
    140 | 141 | {showSubmitButton && ( 142 | 148 | )} 149 |
    150 | ); 151 | }; 152 | 153 | export default RangeSlider; 154 | -------------------------------------------------------------------------------- /src/pages/products/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ProductLayouts from "@/functional-components/product/ProductLayouts"; 3 | import ProductFilters from "@/functional-components/ProductFilters"; 4 | import ProductLayoutViews from "@/functional-components/ProductLayoutViews"; 5 | import Base from "@/layouts/Base.astro"; 6 | import { defaultSort, sorting } from "@/lib/constants"; 7 | import { 8 | getCollectionProducts, 9 | getCollections, 10 | getHighestProductPrice, 11 | getProducts, 12 | getVendors, 13 | } from "@/lib/shopify"; 14 | import type { PageInfo, Product } from "@/lib/shopify/types"; 15 | import CallToAction from "@/partials/CallToAction.astro"; 16 | 17 | const searchParams = Astro.url.searchParams; 18 | const searchParamsObject = Object.fromEntries(searchParams.entries()); 19 | 20 | const sort = searchParamsObject.sort || ""; 21 | const searchValue = searchParamsObject.q || ""; 22 | const minPrice = searchParamsObject.minPrice || ""; 23 | const maxPrice = searchParamsObject.maxPrice || ""; 24 | const brand = searchParamsObject.b || ""; 25 | const category = searchParamsObject.c || "all"; 26 | const tag = searchParamsObject.t || ""; 27 | const cursor = searchParamsObject.cursor || null; 28 | 29 | const { sortKey, reverse } = 30 | sorting.find((item) => item.slug === sort) || defaultSort; 31 | 32 | let productsData: any; 33 | let vendorsWithCounts: { vendor: string; productCount: number }[] = []; 34 | let categoriesWithCounts: { category: string; productCount: number }[] = []; 35 | 36 | if (searchValue || brand || minPrice || maxPrice || category || tag) { 37 | let queryString = ""; 38 | 39 | if (minPrice || maxPrice) { 40 | queryString += `variants.price:<=${maxPrice} variants.price:>=${minPrice}`; 41 | } 42 | 43 | if (searchValue) { 44 | queryString += ` ${searchValue}`; 45 | } 46 | 47 | if (brand) { 48 | Array.isArray(brand) 49 | ? (queryString += `${brand.map((b) => `(vendor:${b})`).join(" OR ")}`) 50 | : (queryString += `vendor:"${brand}"`); 51 | } 52 | 53 | if (tag) { 54 | queryString += ` ${tag}`; 55 | } 56 | 57 | const query = { 58 | sortKey, 59 | reverse, 60 | query: queryString, 61 | cursor: cursor || undefined, 62 | }; 63 | 64 | try { 65 | productsData = 66 | category && category !== "all" 67 | ? await getCollectionProducts({ 68 | collection: category, 69 | sortKey, 70 | reverse, 71 | }) 72 | : await getProducts(query); 73 | } catch (error) { 74 | console.error("Error fetching products:", error); 75 | productsData = { products: [] }; 76 | } 77 | 78 | const uniqueVendors: string[] = [ 79 | ...new Set( 80 | ((productsData?.products as Product[]) || []).map((product: Product) => 81 | String(product?.vendor || "") 82 | ) 83 | ), 84 | ]; 85 | 86 | const uniqueCategories: string[] = [ 87 | ...new Set( 88 | ((productsData?.products as Product[]) || []).flatMap( 89 | (product: Product) => 90 | product.collections.nodes.map( 91 | (collectionNode: any) => collectionNode.title || "" 92 | ) 93 | ) 94 | ), 95 | ]; 96 | 97 | vendorsWithCounts = uniqueVendors.map((vendor: string) => { 98 | const productCount = (productsData?.products || []).filter( 99 | (product: Product) => product?.vendor === vendor 100 | ).length; 101 | return { vendor, productCount }; 102 | }); 103 | 104 | categoriesWithCounts = uniqueCategories.map((category: string) => { 105 | const productCount = ((productsData?.products as Product[]) || []).filter( 106 | (product: Product) => 107 | product.collections.nodes.some( 108 | (collectionNode: any) => collectionNode.title === category 109 | ) 110 | ).length; 111 | return { category, productCount }; 112 | }); 113 | } else { 114 | // Fetch all products 115 | try { 116 | productsData = await getProducts({ 117 | sortKey, 118 | reverse, 119 | cursor: cursor || undefined, 120 | }); 121 | } catch (error) { 122 | console.error("Error fetching products:", error); 123 | productsData = { products: [] }; 124 | } 125 | } 126 | 127 | const categories = await getCollections(); 128 | const vendors = await getVendors({}); 129 | 130 | const tags = [ 131 | ...new Set( 132 | ( 133 | productsData as { pageInfo: PageInfo; products: Product[] } 134 | )?.products.flatMap((product: Product) => product.tags) 135 | ), 136 | ]; 137 | 138 | const maxPriceData = await getHighestProductPrice(); 139 | 140 | const initialProducts = productsData.products; 141 | const initialPageInfo = productsData.pageInfo; 142 | --- 143 | 144 | 145 | 154 | 155 |
    156 |
    157 | 168 | 169 | 177 | 178 |
    179 |
    180 | 181 | -------------------------------------------------------------------------------- /src/layouts/functional-components/cart/AddToCart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import type { ProductVariant } from "@/lib/shopify/types"; 3 | import { BiLoaderAlt } from "react-icons/bi"; 4 | import { addItemToCart } from "@/cartStore"; 5 | 6 | interface SubmitButtonProps { 7 | availableForSale: boolean; 8 | selectedVariantId: string | undefined; 9 | stylesClass: string; 10 | handle: string | null; 11 | pending: boolean; 12 | onClick: (e: React.FormEvent) => void; 13 | } 14 | 15 | function SubmitButton({ 16 | availableForSale, 17 | selectedVariantId, 18 | stylesClass, 19 | handle, 20 | pending, 21 | onClick, 22 | }: SubmitButtonProps) { 23 | const buttonClasses = stylesClass; 24 | const disabledClasses = "cursor-not-allowed flex"; 25 | 26 | const DynamicTag = handle === null ? "button" : "a"; 27 | 28 | if (!availableForSale) { 29 | return ( 30 | 37 | ); 38 | } 39 | 40 | if (!selectedVariantId) { 41 | return ( 42 | 48 | Select Variant 49 | 50 | ); 51 | } 52 | 53 | return ( 54 | 69 | ); 70 | } 71 | interface AddToCartProps { 72 | variants: ProductVariant[]; 73 | availableForSale: boolean; 74 | stylesClass: string; 75 | handle: string | null; 76 | defaultVariantId: string | undefined; 77 | } 78 | export function AddToCart({ 79 | variants, 80 | availableForSale, 81 | stylesClass, 82 | handle, 83 | defaultVariantId, 84 | }: AddToCartProps) { 85 | const [message, setMessage] = useState(null); 86 | const [pending, setPending] = useState(false); 87 | const [selectedVariantId, setSelectedVariantId] = useState(defaultVariantId); 88 | const lastUrl = useRef(window.location.href); 89 | 90 | // Function to update selectedVariantId based on URL 91 | const updateSelectedVariantFromUrl = () => { 92 | const searchParams = new URLSearchParams(window.location.search); 93 | const selectedOptions = Array.from(searchParams.entries()); 94 | 95 | const variant = variants.find((variant) => 96 | selectedOptions.every(([key, value]) => 97 | variant.selectedOptions.some( 98 | (option) => 99 | option.name.toLowerCase() === key && option.value === value, 100 | ), 101 | ), 102 | ); 103 | 104 | setSelectedVariantId(variant?.id || defaultVariantId); 105 | }; 106 | 107 | useEffect(() => { 108 | // Update selected variant on mount and whenever the variants change 109 | updateSelectedVariantFromUrl(); 110 | 111 | // Set up popstate listener for browser navigation 112 | const handlePopState = () => { 113 | updateSelectedVariantFromUrl(); 114 | }; 115 | 116 | // Set up URL change detection 117 | const detectUrlChange = () => { 118 | const currentUrl = window.location.href; 119 | if (currentUrl !== lastUrl.current) { 120 | lastUrl.current = currentUrl; 121 | updateSelectedVariantFromUrl(); 122 | } 123 | }; 124 | 125 | // Set up observers 126 | window.addEventListener("popstate", handlePopState); 127 | 128 | // Check for URL changes every 100ms 129 | const urlCheckInterval = setInterval(detectUrlChange, 100); 130 | 131 | // Clean up 132 | return () => { 133 | window.removeEventListener("popstate", handlePopState); 134 | clearInterval(urlCheckInterval); 135 | }; 136 | }, [variants, defaultVariantId]); 137 | 138 | // Optional: Listen to pushState and replaceState 139 | useEffect(() => { 140 | const originalPushState = history.pushState; 141 | const originalReplaceState = history.replaceState; 142 | 143 | history.pushState = function (...args) { 144 | originalPushState.apply(this, args); 145 | updateSelectedVariantFromUrl(); 146 | }; 147 | 148 | history.replaceState = function (...args) { 149 | originalReplaceState.apply(this, args); 150 | updateSelectedVariantFromUrl(); 151 | }; 152 | 153 | return () => { 154 | history.pushState = originalPushState; 155 | history.replaceState = originalReplaceState; 156 | }; 157 | }, []); 158 | 159 | const handleSubmit = async (e: React.FormEvent) => { 160 | e.preventDefault(); 161 | if (!selectedVariantId) return; 162 | 163 | setPending(true); 164 | try { 165 | const result = await addItemToCart(selectedVariantId); 166 | setMessage(result); 167 | } catch (error: any) { 168 | setMessage(error.message); 169 | } finally { 170 | setPending(false); 171 | } 172 | }; 173 | 174 | return ( 175 |
    176 | 184 |

    185 | {message} 186 |

    187 | 188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /src/layouts/functional-components/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { BiLoaderAlt } from "react-icons/bi"; 3 | 4 | export interface FormData { 5 | firstName?: string; 6 | email: string; 7 | password: string; 8 | } 9 | 10 | const SignUpForm = () => { 11 | const [formData, setFormData] = useState({ 12 | firstName: "", 13 | email: "", 14 | password: "", 15 | }); 16 | 17 | const [loading, setLoading] = useState(false); 18 | const [errorMessages, setErrorMessages] = useState([]); 19 | 20 | const handleChange = (e: React.ChangeEvent) => { 21 | setFormData({ 22 | ...formData, 23 | [e.target.name]: e.target.value, 24 | }); 25 | }; 26 | 27 | const handleSignUp = async (e: React.FormEvent) => { 28 | e.preventDefault(); 29 | 30 | try { 31 | setLoading(true); 32 | const form = new FormData(); 33 | form.append("firstName", formData.firstName || ""); 34 | form.append("email", formData.email); 35 | form.append("password", formData.password); 36 | 37 | const response = await fetch("/api/sign-up", { 38 | method: "POST", 39 | body: form, // Use FormData 40 | }); 41 | 42 | const contentType = response.headers.get("content-type"); 43 | 44 | if (contentType && contentType.includes("application/json")) { 45 | const responseData = await response.json(); 46 | 47 | if (response.ok) { 48 | setErrorMessages([]); 49 | localStorage.setItem("user", JSON.stringify(responseData)); 50 | window.location.href = "/"; 51 | } else { 52 | const errors = responseData.errors || [ 53 | { message: "Sign-up failed." }, 54 | ]; 55 | setErrorMessages(errors.map((error: any) => error.message)); 56 | } 57 | } else { 58 | setErrorMessages(["Invalid response from the server."]); 59 | } 60 | } catch (error) { 61 | console.error("Error during sign-up:", error); 62 | setErrorMessages(["An error occurred. Please try again."]); 63 | } finally { 64 | setLoading(false); 65 | } 66 | }; 67 | 68 | return ( 69 |
    70 |
    71 |
    72 |
    73 |
    74 |

    Create an account

    75 |

    Create an account and start using...

    76 |
    77 | 78 |
    79 |
    80 | 81 | 90 |
    91 | 92 |
    93 | 94 | 103 |
    104 | 105 |
    106 | 107 | 116 |
    117 | 118 | {errorMessages.length > 0 && 119 | errorMessages.map((error, index) => ( 120 |

    121 | *{error} 122 |

    123 | ))} 124 | 125 | 135 |
    136 | 137 |
    138 |

    139 | I have read and agree to the 140 |

    141 | 145 | Terms & Conditions 146 | 147 |
    148 | 149 |
    150 |

    151 | Have an account? 152 |

    153 | 157 | Login 158 | 159 |
    160 |
    161 |
    162 |
    163 |
    164 | ); 165 | }; 166 | 167 | export default SignUpForm; 168 | --------------------------------------------------------------------------------