factory | cleanup
@ -1,7 +0,0 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
@ -1,14 +0,0 @@
|
||||
; https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@ -1,3 +0,0 @@
|
||||
PUBLIC_SHOPIFY_API_SECRET_KEY=""
|
||||
PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
||||
PUBLIC_SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
|
||||
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "LLaMA-Factory"]
|
||||
path = LLaMA-Factory
|
||||
url = https://github.com/hiyouga/LLaMA-Factory.git
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"MD033": false,
|
||||
"MD013": false
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode","bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
@ -1,5 +0,0 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.mdx": "markdown"
|
||||
}
|
||||
}
|
||||
43
Dockerfile
@ -1,43 +0,0 @@
|
||||
ARG INSTALLER=yarn
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
ARG INSTALLER
|
||||
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ "${INSTALLER}" == "yarn" ]; then yarn --frozen-lockfile; \
|
||||
elif [ "${INSTALLER}" == "npm" ]; then npm ci; \
|
||||
elif [ "${INSTALLER}" == "pnpm" ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Valid installer not set." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# RUN chmod u+x ./installer && ./installer
|
||||
ARG INSTALLER
|
||||
RUN \
|
||||
if [ "${INSTALLER}" == "yarn" ]; then yarn build; \
|
||||
elif [ "${INSTALLER}" == "npm" ]; then npm run build; \
|
||||
elif [ "${INSTALLER}" == "pnpm" ]; then pnpm run build; \
|
||||
else echo "Valid installer not set." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run nginx
|
||||
FROM nginx:alpine AS runner
|
||||
COPY ./config/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Themefisher
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1
LLaMA-Factory
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 1f47b6186c267de86cbdbd47ba2adbf1f9db7f39
|
||||
154
README.md
@ -1,154 +0,0 @@
|
||||
<h1 align=center>Astrofront | AstroJs + Shopify + Tailwind CSS + TypeScript Starter and Boilerplate</h1>
|
||||
|
||||
<p align=center>A free, production-ready astro.js template powered by Tailwind CSS and TypeScript, specifically designed for Shopify. Utilizes the Shopify Storefront API through GraphQL and providing everything you need to jumpstart your Astro project and save valuable time.</p>
|
||||
|
||||
<p align=center>Made with ♥ by <a href="https://themefisher.com/">Themefisher</a></p>
|
||||
<p align=center> If you find this project useful, please give it a ⭐ to show your support. </p>
|
||||
|
||||
<h2 align="center"> <a target="_blank" href="https://astrofront.vercel.app/" rel="nofollow">👀 Demo</a> | <a target="_blank" href="https://pagespeed.web.dev/analysis/https-astrofront-vercel-app/qs3wscwqpq?form_factor=desktop">Page Speed (99%)🚀</a>
|
||||
</h2>
|
||||
|
||||
<p align=center>
|
||||
|
||||
<a href="https://github.com/withastro/astro/releases/tag/astro@4.16.8" alt="Contributors">
|
||||
<img src="https://img.shields.io/static/v1?label=ASTRO&message=4.16&color=BC52EE&logo=astro" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/themefisher/astrofront/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/themefisher/astrofront" alt="license"></a>
|
||||
|
||||
<img src="https://img.shields.io/github/languages/code-size/themefisher/astrofront" alt="code size">
|
||||
|
||||
<a href="https://github.com/themefisher/astrofront/graphs/contributors">
|
||||
<img src="https://img.shields.io/github/contributors/themefisher/astrofront" alt="contributors"></a>
|
||||
</p>
|
||||
|
||||
## 📌 Key Features
|
||||
|
||||
- 🌐 Dynamic Products from Shopify Storefront API
|
||||
- 💸 Checkout and Payments with Shopify
|
||||
- 🌞 Automatic Light/Dark Mode
|
||||
- 🚀 Fetching and Caching Paradigms
|
||||
- 🔗 Server Actions for Mutations
|
||||
- 🔐 User Authentication
|
||||
- 🧩 Similar Products Suggestions
|
||||
- 🔍 Search, Sort, Different Views Functionality
|
||||
- 🏷️ Tags & Categories & Vendors & Price Range & Product Variants Functionality
|
||||
- 🖼️ Single Product Image Zoom, Hover Effect, Slider
|
||||
- 🛒 Cart & Easy editing options for cart items
|
||||
- 📝 Product Description on Multiple Tabs
|
||||
- 🔗 Netlify Setting Pre-configured
|
||||
- 📞 Support Contact Form
|
||||
- 📱 Fully Responsive
|
||||
- 🔄 Dynamic Home Banner Slider
|
||||
- 📝 Write and Update Content in Markdown / MDX
|
||||
- ⌛ Infinite Product Load on Scrolling
|
||||
|
||||
### 📄 10+ Pre-designed Pages
|
||||
|
||||
- 🏠 Homepage
|
||||
- 👤 About
|
||||
- 📞 Contact
|
||||
- 🛍️ Products
|
||||
- 📦 Product Single
|
||||
- 💡 Terms of services
|
||||
- 📄 Privacy Policy
|
||||
- 🔐 Login
|
||||
- 🔑 Register
|
||||
- 🚫 Custom 404
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- shopify
|
||||
- astro 5.1+
|
||||
- node v20.10+
|
||||
- npm v10.2+
|
||||
- tailwind v3.4+
|
||||
|
||||
<!-- get Shopify storefront API access token-->
|
||||
|
||||
## 🛒 Retrieve Shopify Token & Add Demo Products
|
||||
|
||||
- To get the tokens needed, create a Shopify partner account.
|
||||

|
||||
|
||||
- Now go to 'stores' and select 'Add store.' Create a development store using the option 'Create development store'.
|
||||

|
||||
|
||||
- Click on import products.
|
||||

|
||||
|
||||
- Locate the 'products' CSV file in the public folder of the repository and upload it for demo products.
|
||||

|
||||
|
||||
- On the admin dashboard, click on ‘Settings’ at the bottom of the left sidebar.
|
||||

|
||||
|
||||
- On the Settings page, click on ‘Apps and sales channels’ on the left sidebar.
|
||||

|
||||
|
||||
- In the Apps and sales channels page that opens, click on ‘Develop apps’ on the top right.
|
||||

|
||||
|
||||
- Now, on the App development page that opens, click on ‘Create an app’.
|
||||

|
||||
|
||||
- A ‘Create an app’ popup opens. Fill in any name in the ‘App Name’ text box. In the App Developer text box, your name and email id is automatically fetched. Else type in the same email id you used while signing up for the Shopify store.
|
||||

|
||||
|
||||
- Next, click on ‘Configure’ in the Storefront API integration section.
|
||||

|
||||
|
||||
- In the Storefront API access scopes, select and check all the boxes and click on ‘Save’ and then ‘Install app’.
|
||||

|
||||
|
||||
- Navigate to the 'API credentials' tab and locate three essential pieces of information. Subsequently, update your `.env` file by replacing the placeholder quotes("") in the `.env.example` file with your Shopify credentials.
|
||||

|
||||
|
||||
- When adding your product, use the same alt title for images with the same color. This helps the first image appear as the color variant in the selector.
|
||||

|
||||

|
||||
|
||||
- We have the option to create additional collections for products.
|
||||

|
||||
|
||||
### 👉 Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 👉 Development Command
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 👉 Build Command
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
<!-- reporting issue -->
|
||||
|
||||
## 🐞 Reporting Issues
|
||||
|
||||
We use GitHub Issues as the official bug tracker for this Template. Please Search [existing issues](https://github.com/themefisher/astrofront/issues). It’s possible someone has already reported the same problem.
|
||||
If your problem or idea has not been addressed yet, feel free to [open a new issue](https://github.com/themefisher/astrofront/issues).
|
||||
|
||||
<!-- licence -->
|
||||
|
||||
## 📝 License
|
||||
|
||||
Copyright (c) 2024 - Present, Designed & Developed by [Themefisher](https://themefisher.com/)
|
||||
|
||||
**Code License:** Released under the [MIT](https://github.com/themefisher/astrofront/blob/main/LICENSE) license.
|
||||
|
||||
**Image license:** The images are only for demonstration purposes. They have their license, we don't have permission to share those images.
|
||||
|
||||
## 💻 Need Custom Development Services?
|
||||
|
||||
If you need a custom theme, theme customization, or complete website development services from scratch you can [Hire Us](https://themefisher.com/).
|
||||
@ -1,56 +0,0 @@
|
||||
import mdx from "@astrojs/mdx";
|
||||
import react from "@astrojs/react";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import AutoImport from "astro-auto-import";
|
||||
import { defineConfig } from "astro/config";
|
||||
import remarkCollapse from "remark-collapse";
|
||||
import remarkToc from "remark-toc";
|
||||
import config from "./src/config/config.json";
|
||||
|
||||
import vercel from "@astrojs/vercel";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: config.site.base_url ? config.site.base_url : "http://examplesite.com",
|
||||
base: config.site.base_path ? config.site.base_path : "/",
|
||||
trailingSlash: config.site.trailing_slash ? "always" : "never",
|
||||
output: "server",
|
||||
|
||||
integrations: [
|
||||
react(),
|
||||
sitemap(),
|
||||
tailwind({ applyBaseStyles: false }),
|
||||
AutoImport({
|
||||
imports: [
|
||||
"@/shortcodes/Button",
|
||||
"@/shortcodes/Accordion",
|
||||
"@/shortcodes/Notice",
|
||||
"@/shortcodes/Video",
|
||||
"@/shortcodes/Youtube",
|
||||
"@/shortcodes/Tabs",
|
||||
"@/shortcodes/Tab",
|
||||
],
|
||||
}),
|
||||
mdx(),
|
||||
],
|
||||
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkToc,
|
||||
[
|
||||
remarkCollapse,
|
||||
{
|
||||
test: "Table of contents",
|
||||
},
|
||||
],
|
||||
],
|
||||
shikiConfig: {
|
||||
theme: "one-dark-pro",
|
||||
wrap: true,
|
||||
},
|
||||
extendDefaultPlugins: true,
|
||||
},
|
||||
|
||||
adapter: vercel(),
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri ${uri}.html $uri/index.html =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
[build]
|
||||
publish = "dist"
|
||||
command = "yarn build"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "20"
|
||||
73
package.json
@ -1,73 +0,0 @@
|
||||
{
|
||||
"name": "Astrofront",
|
||||
"version": "2.0.0",
|
||||
"description": "Astro and Tailwindcss boilerplate",
|
||||
"author": "Themefisher",
|
||||
"license": "MIT",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"format": "prettier -w ./src",
|
||||
"check": "astro check",
|
||||
"remove-darkmode": "node scripts/removeDarkmode.js && yarn format"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "4.0.5",
|
||||
"@astrojs/netlify": "6.0.1",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "4.1.3",
|
||||
"@astrojs/rss": "4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/tailwind": "5.1.4",
|
||||
"@astrojs/vercel": "^8.0.1",
|
||||
"@nanostores/react": "^0.8.4",
|
||||
"astro": "5.1.5",
|
||||
"astro-auto-import": "^0.4.4",
|
||||
"astro-font": "^0.1.81",
|
||||
"date-fns": "^4.1.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"marked": "^15.0.6",
|
||||
"multi-range-slider-react": "^2.0.7",
|
||||
"nanostores": "^0.11.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-collapsed": "^4.2.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-gravatar": "^2.6.3",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-inner-image-zoom": "^3.0.2",
|
||||
"react-lite-youtube-embed": "^2.4.0",
|
||||
"remark-collapse": "^0.1.2",
|
||||
"remark-toc": "^9.0.0",
|
||||
"swiper": "^11.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "19.0.4",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/react-gravatar": "^2.6.14",
|
||||
"@types/react-inner-image-zoom": "^3.0.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.18.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"sass": "^1.83.1",
|
||||
"sharp": "0.33.5",
|
||||
"tailwind-bootstrap-grid": "^5.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@ -1,88 +0,0 @@
|
||||
##### Optimize default expiration time - BEGIN
|
||||
<IfModule mod_expires.c>
|
||||
|
||||
## Enable expiration control
|
||||
ExpiresActive On
|
||||
|
||||
## CSS and JS expiration: 1 week after request
|
||||
ExpiresByType text/css "now plus 1 week"
|
||||
ExpiresByType application/javascript "now plus 1 week"
|
||||
ExpiresByType application/x-javascript "now plus 1 week"
|
||||
|
||||
## Image files expiration: 1 month after request
|
||||
ExpiresByType image/bmp "now plus 1 month"
|
||||
ExpiresByType image/gif "now plus 1 month"
|
||||
ExpiresByType image/jpeg "now plus 1 month"
|
||||
ExpiresByType image/webp "now plus 1 month"
|
||||
ExpiresByType image/jp2 "now plus 1 month"
|
||||
ExpiresByType image/pipeg "now plus 1 month"
|
||||
ExpiresByType image/png "now plus 1 month"
|
||||
ExpiresByType image/svg+xml "now plus 1 month"
|
||||
ExpiresByType image/tiff "now plus 1 month"
|
||||
ExpiresByType image/x-icon "now plus 1 month"
|
||||
ExpiresByType image/ico "now plus 1 month"
|
||||
ExpiresByType image/icon "now plus 1 month"
|
||||
ExpiresByType text/ico "now plus 1 month"
|
||||
ExpiresByType application/ico "now plus 1 month"
|
||||
ExpiresByType image/vnd.wap.wbmp "now plus 1 month"
|
||||
|
||||
## Font files expiration: 1 month after request
|
||||
ExpiresByType application/x-font-ttf "now plus 1 month"
|
||||
ExpiresByType application/x-font-opentype "now plus 1 month"
|
||||
ExpiresByType application/x-font-woff "now plus 1 month"
|
||||
ExpiresByType font/woff2 "now plus 1 month"
|
||||
ExpiresByType image/svg+xml "now plus 1 month"
|
||||
|
||||
## Audio files expiration: 1 month after request
|
||||
ExpiresByType audio/ogg "now plus 1 month"
|
||||
ExpiresByType application/ogg "now plus 1 month"
|
||||
ExpiresByType audio/basic "now plus 1 month"
|
||||
ExpiresByType audio/mid "now plus 1 month"
|
||||
ExpiresByType audio/midi "now plus 1 month"
|
||||
ExpiresByType audio/mpeg "now plus 1 month"
|
||||
ExpiresByType audio/mp3 "now plus 1 month"
|
||||
ExpiresByType audio/x-aiff "now plus 1 month"
|
||||
ExpiresByType audio/x-mpegurl "now plus 1 month"
|
||||
ExpiresByType audio/x-pn-realaudio "now plus 1 month"
|
||||
ExpiresByType audio/x-wav "now plus 1 month"
|
||||
|
||||
## Movie files expiration: 1 month after request
|
||||
ExpiresByType application/x-shockwave-flash "now plus 1 month"
|
||||
ExpiresByType x-world/x-vrml "now plus 1 month"
|
||||
ExpiresByType video/x-msvideo "now plus 1 month"
|
||||
ExpiresByType video/mpeg "now plus 1 month"
|
||||
ExpiresByType video/mp4 "now plus 1 month"
|
||||
ExpiresByType video/quicktime "now plus 1 month"
|
||||
ExpiresByType video/x-la-asf "now plus 1 month"
|
||||
ExpiresByType video/x-ms-asf "now plus 1 month"
|
||||
</IfModule>
|
||||
##### Optimize default expiration time - END
|
||||
|
||||
##### 1 Month for most static resources
|
||||
<filesMatch ".(css|jpg|jpeg|png|webp|gif|js|ico|woff|woff2|eot|ttf)$">
|
||||
Header set Cache-Control "max-age=2592000, public"
|
||||
</filesMatch>
|
||||
|
||||
##### Enable gzip compression for resources
|
||||
<ifModule mod_gzip.c>
|
||||
mod_gzip_on Yes
|
||||
mod_gzip_dechunk Yes
|
||||
mod_gzip_item_include file .(html?|txt|css|js|php)$
|
||||
mod_gzip_item_include handler ^cgi-script$
|
||||
mod_gzip_item_include mime ^text/.*
|
||||
mod_gzip_item_include mime ^application/x-javascript.*
|
||||
mod_gzip_item_exclude mime ^image/.*
|
||||
mod_gzip_item_exclude rspheader ^Content-Encoding:.*gzip.*
|
||||
</ifModule>
|
||||
|
||||
##### Or, compress certain file types by extension:
|
||||
<FilesMatch ".(html|css|jpg|jpeg|webp|png|gif|js|ico)">
|
||||
SetOutputFilter DEFLATE
|
||||
</FilesMatch>
|
||||
|
||||
##### Set Header Vary: Accept-Encoding
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch ".(js|css|xml|gz|html)$">
|
||||
Header append Vary: Accept-Encoding
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 925 B |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M160 110V80H140.156H120.312V78C120.312 71.9375 122.938 64 127.031 57.7812C128.969 54.8125 134.812 48.9688 137.781 47.0312C144 42.9375 151.938 40.3125 158 40.3125H160V30.1562V20H157.25C154.281 20 148.75 20.8125 144.844 21.8438C130.25 25.5937 117 35.3125 109.062 48.0937C104.656 55.2187 102 62.3437 100.594 70.9375C100.062 74.2812 100 76.7188 100 107.281V140H130H160L160 110Z" fill="#D9D9D9"/>
|
||||
<path d="M60 110L60 80H40.1562H20.3125V78C20.3125 71.9375 22.9375 64 27.0312 57.7812C28.9687 54.8125 34.8125 48.9688 37.7812 47.0312C44 42.9375 51.9375 40.3125 58 40.3125H60V30.1562V20H57.25C54.2812 20 48.75 20.8125 44.8438 21.8438C30.25 25.5937 17 35.3125 9.0625 48.0937C4.65625 55.2187 2 62.3437 0.59375 70.9375C0.0625 74.2812 0 76.7188 0 107.281V140H30H60V110Z" fill="#D9D9D9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 890 B |
|
Before Width: | Height: | Size: 10 KiB |
@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /api/*
|
||||
@ -1,96 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
(function () {
|
||||
const rootDirs = ["src/pages", "src/hooks", "src/layouts", "src/styles"];
|
||||
|
||||
const deleteAssetList = [
|
||||
"public/images/logo-darkmode.png",
|
||||
"src/layouts/components/ThemeSwitcher.astro",
|
||||
];
|
||||
|
||||
const configFiles = [
|
||||
{
|
||||
filePath: "tailwind.config.js",
|
||||
patterns: ["darkmode:\\s*{[^}]*},", 'darkMode:\\s*"class",'],
|
||||
},
|
||||
{ filePath: "src/config/theme.json", patterns: ["colors.darkmode"] },
|
||||
];
|
||||
|
||||
const filePaths = [
|
||||
{
|
||||
filePath: "src/layouts/partials/Header.astro",
|
||||
patterns: [
|
||||
"<ThemeSwitchers*(?:\\s+[^>]+)?\\s*(?:\\/\\>|>([\\s\\S]*?)<\\/ThemeSwitchers*>)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
filePaths.forEach(({ filePath, patterns }) => {
|
||||
removeDarkModeFromFiles(filePath, patterns);
|
||||
});
|
||||
|
||||
deleteAssetList.forEach(deleteAsset);
|
||||
function deleteAsset(asset) {
|
||||
try {
|
||||
fs.unlinkSync(asset);
|
||||
console.log(`${path.basename(asset)} deleted successfully!`);
|
||||
} catch (error) {
|
||||
console.error(`${asset} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
rootDirs.forEach(removeDarkModeFromPages);
|
||||
configFiles.forEach(removeDarkMode);
|
||||
|
||||
function removeDarkModeFromFiles(filePath, regexPatterns) {
|
||||
const fileContent = fs.readFileSync(filePath, "utf8");
|
||||
let updatedContent = fileContent;
|
||||
regexPatterns.forEach((pattern) => {
|
||||
const regex = new RegExp(pattern, "g");
|
||||
updatedContent = updatedContent.replace(regex, "");
|
||||
});
|
||||
fs.writeFileSync(filePath, updatedContent, "utf8");
|
||||
}
|
||||
|
||||
function removeDarkModeFromPages(directoryPath) {
|
||||
const files = fs.readdirSync(directoryPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(directoryPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
removeDarkModeFromPages(filePath);
|
||||
} else if (stats.isFile()) {
|
||||
removeDarkModeFromFiles(filePath, [
|
||||
'(?:(?!["])\\S)*dark:(?:(?![,;"])\\S)*',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeDarkMode(configFile) {
|
||||
const { filePath, patterns } = configFile;
|
||||
if (filePath === "tailwind.config.js") {
|
||||
removeDarkModeFromFiles(filePath, patterns);
|
||||
} else {
|
||||
const contentFile = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
patterns.forEach((pattern) => deleteNestedProperty(contentFile, pattern));
|
||||
fs.writeFileSync(filePath, JSON.stringify(contentFile));
|
||||
}
|
||||
}
|
||||
|
||||
function deleteNestedProperty(obj, propertyPath) {
|
||||
const properties = propertyPath.split(".");
|
||||
let currentObj = obj;
|
||||
for (let i = 0; i < properties.length - 1; i++) {
|
||||
const property = properties[i];
|
||||
if (currentObj.hasOwnProperty(property)) {
|
||||
currentObj = currentObj[property];
|
||||
} else {
|
||||
return; // Property not found, no need to continue
|
||||
}
|
||||
}
|
||||
delete currentObj[properties[properties.length - 1]];
|
||||
}
|
||||
})();
|
||||
0
src/.gitignore
vendored
@ -1,74 +0,0 @@
|
||||
import { atom, computed } from "nanostores";
|
||||
import Cookies from "js-cookie";
|
||||
import { getCart } from "@/lib/shopify";
|
||||
import {
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
} from "@/lib/utils/cartActions";
|
||||
import type { Cart } from "@/lib/shopify/types";
|
||||
|
||||
// Atom to hold the cart state
|
||||
export const cart = atom<Cart | null>(null);
|
||||
|
||||
// Computed store for total quantity in the cart
|
||||
export const totalQuantity = computed(cart, (c) => (c ? c.totalQuantity : 0));
|
||||
|
||||
// Atom to manage the layout view state (card or list)
|
||||
export const layoutView = atom<"card" | "list">("card");
|
||||
|
||||
// Function to set a new layout view
|
||||
export function setLayoutView(view: "card" | "list") {
|
||||
layoutView.set(view);
|
||||
}
|
||||
|
||||
// Function to get the current layout view
|
||||
export function getLayoutView() {
|
||||
return layoutView.get();
|
||||
}
|
||||
|
||||
// Update cart state in the store
|
||||
export async function refreshCartState() {
|
||||
const cartId = Cookies.get("cartId");
|
||||
if (cartId) {
|
||||
const currentCart = await getCart(cartId);
|
||||
cart.set(currentCart as any);
|
||||
}
|
||||
}
|
||||
|
||||
// Add item to the cart and update state
|
||||
export async function addItemToCart(selectedVariantId: string) {
|
||||
try {
|
||||
await addItem(selectedVariantId);
|
||||
await refreshCartState();
|
||||
return "Added to cart";
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message || "Failed to add to cart");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove item from the cart and update state
|
||||
export async function removeItemFromCart(lineId: string) {
|
||||
try {
|
||||
await removeItem(lineId);
|
||||
await refreshCartState();
|
||||
return "Removed from cart";
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message || "Failed to remove item from cart");
|
||||
}
|
||||
}
|
||||
|
||||
// Update item quantity in the cart and update state
|
||||
export async function updateCartItemQuantity(payload: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}) {
|
||||
try {
|
||||
await updateItemQuantity(payload);
|
||||
await refreshCartState();
|
||||
return "Cart updated";
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message || "Failed to update cart");
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
{
|
||||
"site": {
|
||||
"title": "Astrofront",
|
||||
"base_url": "https://astrofront.vercel.app/",
|
||||
"base_path": "/",
|
||||
"trailing_slash": false,
|
||||
"favicon": "/images/favicon.png",
|
||||
"logo": "/images/logo.png",
|
||||
"logo_darkmode": "/images/logo-darkmode.png",
|
||||
"logo_width": "150",
|
||||
"logo_height": "33",
|
||||
"logo_text": "Astrofront"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"search": true,
|
||||
"account": true,
|
||||
"sticky_header": true,
|
||||
"theme_switcher": true,
|
||||
"default_theme": "system"
|
||||
},
|
||||
|
||||
"params": {
|
||||
"contact_form_action": "#",
|
||||
"copyright": "Designed And Developed by [Themefisher](https://themefisher.com/)"
|
||||
},
|
||||
|
||||
"navigation_button": {
|
||||
"enable": true,
|
||||
"label": "Get Started",
|
||||
"link": "https://github.com/themefisher/astrofront"
|
||||
},
|
||||
|
||||
"metadata": {
|
||||
"meta_author": "Themefisher",
|
||||
"meta_image": "/images/og-image.png",
|
||||
"meta_description": "Shopify Storefront Boilerplate"
|
||||
},
|
||||
|
||||
"shopify": {
|
||||
"currencySymbol": "৳",
|
||||
"currencyCode": "BDT",
|
||||
"collections": {
|
||||
"hero_slider": "hidden-homepage-carousel",
|
||||
"featured_products": "featured-products"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
{
|
||||
"main": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "/"
|
||||
},
|
||||
{
|
||||
"name": "Products",
|
||||
"url": "/products"
|
||||
},
|
||||
{
|
||||
"name": "Pages",
|
||||
"url": "",
|
||||
"hasChildren": true,
|
||||
"children": [
|
||||
{
|
||||
"name": "About",
|
||||
"url": "/about"
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"url": "/contact"
|
||||
},
|
||||
{
|
||||
"name": "404 Page",
|
||||
"url": "/404"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"url": "/contact"
|
||||
}
|
||||
],
|
||||
"footer": [
|
||||
{
|
||||
"name": "About",
|
||||
"url": "/about"
|
||||
},
|
||||
{
|
||||
"name": "Products",
|
||||
"url": "/products"
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"url": "/contact"
|
||||
}
|
||||
],
|
||||
"footerCopyright": [
|
||||
{
|
||||
"name": "Privacy & Policy",
|
||||
"url": "/privacy-policy"
|
||||
},
|
||||
{
|
||||
"name": "Terms of Service",
|
||||
"url": "/terms-services"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"main": [
|
||||
{
|
||||
"name": "facebook",
|
||||
"icon": "FaFacebookF",
|
||||
"link": "https://www.facebook.com/themefisher"
|
||||
},
|
||||
{
|
||||
"name": "twitter",
|
||||
"icon": "FaXTwitter",
|
||||
"link": "https://x.com/themefisher"
|
||||
},
|
||||
{
|
||||
"name": "linkedin",
|
||||
"icon": "FaLinkedinIn",
|
||||
"link": "https://bd.linkedin.com/company/themefisher"
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"icon": "FaGithub",
|
||||
"link": "https://github.com/themefisher/astrofront"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"colors": {
|
||||
"default": {
|
||||
"theme_color": {
|
||||
"primary": "#121212",
|
||||
"body": "#fff",
|
||||
"border": "#eaeaea",
|
||||
"theme_light": "#f2f2f2",
|
||||
"theme_dark": "#000"
|
||||
},
|
||||
"text_color": {
|
||||
"default": "#444",
|
||||
"dark": "#000",
|
||||
"light": "#666"
|
||||
}
|
||||
},
|
||||
"darkmode": {
|
||||
"theme_color": {
|
||||
"primary": "#fff",
|
||||
"body": "#252525",
|
||||
"border": "#3E3E3E",
|
||||
"theme_light": "#222222",
|
||||
"theme_dark": "#000"
|
||||
},
|
||||
"text_color": {
|
||||
"default": "#DDD",
|
||||
"dark": "#fff",
|
||||
"light": "#DDD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"font_family": {
|
||||
"primary": "Karla:wght@400;500;700",
|
||||
"primary_type": "sans-serif",
|
||||
"secondary": "",
|
||||
"secondary_type": ""
|
||||
},
|
||||
"font_size": {
|
||||
"base": "16",
|
||||
"scale": "1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { glob } from "astro/loaders";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { aboutCollection } from "./types/pages/aboutCollection";
|
||||
import { contactCollection } from "./types/pages/contactCollection";
|
||||
import { ctaSectionCollection } from "./types/sections/ctaSectionCollection";
|
||||
import { paymentCollection } from "./types/sections/paymentCollection";
|
||||
|
||||
// Pages collection schema
|
||||
const pagesCollection = defineCollection({
|
||||
loader: glob({ pattern: "**/*.{md,mdx}", base: "src/content/pages" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
meta_title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Export collections
|
||||
export const collections = {
|
||||
// Pages
|
||||
pages: pagesCollection,
|
||||
about: aboutCollection,
|
||||
contact: contactCollection,
|
||||
|
||||
// sections
|
||||
ctaSection: ctaSectionCollection,
|
||||
paymentSection: paymentCollection,
|
||||
};
|
||||
@ -1,85 +0,0 @@
|
||||
---
|
||||
title: "About Us"
|
||||
meta_title: "About"
|
||||
description: ""
|
||||
image: ""
|
||||
draft: false
|
||||
|
||||
#About Us
|
||||
about_us:
|
||||
- title: "Our Company"
|
||||
image: "/images/aboutUs.png"
|
||||
content: "Welcome to **Astrofront** where brilliance meets innovation. We take pride in being your ultimate destination for exquisite lighting solutions that illuminate spaces and lives alike. With a passion for creating luminous experiences, we curate a diverse range of cutting-edge light fixtures designed to elevate any environment. Our commitment to quality craftsmanship and a keen eye for aesthetic appeal ensures that each product we offer is not just a source of light,<br/><br/> but a work of art in its own right. Whether you're seeking ambient elegance for your home or functional brilliance for a commercial space, [Your Company Name] is dedicated to bringing your vision to light. Explore our collection and let your surroundings shine with a touch of our radiant expertise. Elevate your space, embrace the light, only with **Astrofront**."
|
||||
|
||||
- title: "Who We Are ?"
|
||||
image: "/images/aboutUs.png"
|
||||
content: "At **Astrofront** we illuminate your world with a curated collection of exceptional lighting solutions. Established with a passion for transforming spaces and creating ambiance, we stand as a beacon of quality and style in the realm of lighting. With a keen eye for design and a commitment to sourcing the finest materials, we pride ourselves on offering a diverse range of work that not only brighten spaces but also elevate aesthetics. <br/><br/> Committed to delivering excellence, we prioritize customer satisfaction and provide expert guidance to help you find the perfect lighting solution for any setting. Welcome to **Astrofront** where light meets inspiration. Illuminate your world today!"
|
||||
|
||||
# Frequently Asked Questions
|
||||
faq_section_title: "Frequently Asked Questions"
|
||||
faq_section_subtitle: "Our expertly crafted FAQ guide provides valuable insights on selecting the perfect table lamp to complement your decor and meet your specific lighting needs."
|
||||
button:
|
||||
enable: true
|
||||
label: "Contact Us"
|
||||
link: "/contact"
|
||||
faqs:
|
||||
- title: "Can I customize lamps for client projects?"
|
||||
content: "Yes, our platform allows customization for client projects, ensuring unique and tailored solutions. Yes, our platform allows customization for client projects, ensuring unique and tailored solutions."
|
||||
|
||||
- title: "Where are your lamps crafted?"
|
||||
content: "Our lamps are meticulously crafted, combining quality materials and skilled workmanship to deliver exceptional products. Our lamps are meticulously crafted, combining quality materials and skilled workmanship to deliver exceptional products."
|
||||
|
||||
- title: "What's included in the 'free updates' policy?"
|
||||
content: "Free updates encompass enhancements to lamp designs and features, ensuring your collection stays current and appealing. Free updates encompass enhancements to lamp designs and features, ensuring your collection stays current and appealing"
|
||||
|
||||
- title: "Can I use your lamps for open source projects?"
|
||||
content: "Certainly! Our lamps are open for integration into various projects, fostering creativity and innovation. Certainly! Our lamps are open for integration into various projects, fostering creativity and innovation."
|
||||
|
||||
- title: "Can I retail themes featuring your lamps?"
|
||||
content: "Absolutely! You can sell themes created with our lamps, providing stylish solutions for diverse design needs. Absolutely! You can sell themes created with our lamps, providing stylish solutions for diverse design needs."
|
||||
|
||||
# Testimonials
|
||||
testimonials_section_enable: true
|
||||
testimonials_section_title: "What Our Client Says"
|
||||
testimonials:
|
||||
- name: "Ava Sinclair"
|
||||
designation: "Lead Frontend Architect"
|
||||
avatar: "/images/avatar-sm.png"
|
||||
content: "Astrofront has been a game-changer for our e-commerce setup. This Astro-Shopify boilerplate blends the speed and flexibility of Astro with the powerful e-commerce capabilities of Shopify, giving us the perfect foundation for a high-performance, modern storefront. Setup was smooth, and it’s optimized for seamless integration with Shopify’s API, so we were able to get our site up and running quickly without compromising on customizations or functionality. Astrofront’s clean codebase and scalability make it an ideal solution for any team looking to leverage Astro’s benefits in an e-commerce context. Highly recommended for anyone looking to streamline their Shopify store with the speed of Astro!"
|
||||
|
||||
- name: "Jordan Patel"
|
||||
designation: "E-commerce Solutions Strategist"
|
||||
avatar: "/images/avatar-sm.png"
|
||||
content: "Astrofront has been a game-changer for our e-commerce setup. This Astro-Shopify boilerplate blends the speed and flexibility of Astro with the powerful e-commerce capabilities of Shopify, giving us the perfect foundation for a high-performance, modern storefront. Setup was smooth, and it’s optimized for seamless integration with Shopify’s API, so we were able to get our site up and running quickly without compromising on customizations or functionality. Astrofront’s clean codebase and scalability make it an ideal solution for any team looking to leverage Astro’s benefits in an e-commerce context. Highly recommended for anyone looking to streamline their Shopify store with the speed of Astro!"
|
||||
|
||||
- name: "Lena Brooks"
|
||||
designation: "Digital Experience Specialist"
|
||||
avatar: "/images/avatar-sm.png"
|
||||
content: "Astrofront has been a game-changer for our e-commerce setup. This Astro-Shopify boilerplate blends the speed and flexibility of Astro with the powerful e-commerce capabilities of Shopify, giving us the perfect foundation for a high-performance, modern storefront. Setup was smooth, and it’s optimized for seamless integration with Shopify’s API, so we were able to get our site up and running quickly without compromising on customizations or functionality. Astrofront’s clean codebase and scalability make it an ideal solution for any team looking to leverage Astro’s benefits in an e-commerce context. Highly recommended for anyone looking to streamline their Shopify store with the speed of Astro!"
|
||||
|
||||
- name: "Marvin McKinney"
|
||||
designation: "Web Designer"
|
||||
avatar: "/images/avatar-sm.png"
|
||||
content: "Astrofront has been a game-changer for our e-commerce setup. This Astro-Shopify boilerplate blends the speed and flexibility of Astro with the powerful e-commerce capabilities of Shopify, giving us the perfect foundation for a high-performance, modern storefront. Setup was smooth, and it’s optimized for seamless integration with Shopify’s API, so we were able to get our site up and running quickly without compromising on customizations or functionality. Astrofront’s clean codebase and scalability make it an ideal solution for any team looking to leverage Astro’s benefits in an e-commerce context. Highly recommended for anyone looking to streamline their Shopify store with the speed of Astro!"
|
||||
|
||||
# Our Staff
|
||||
staff_section_enable: true
|
||||
staff:
|
||||
- name: "Marvin McKinney"
|
||||
designation: "Web Designer"
|
||||
avatar: "/images/staff/staff.png"
|
||||
|
||||
- name: "Noah Anderson"
|
||||
designation: "Java Engineer"
|
||||
avatar: "/images/staff/staff.png"
|
||||
|
||||
- name: "Olivia Harper"
|
||||
designation: "UI Designer"
|
||||
avatar: "/images/staff/staff.png"
|
||||
|
||||
- name: "Benjamin Clarke"
|
||||
designation: "Product Marketer"
|
||||
avatar: "/images/staff/staff.png"
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Corporis illum nesciunt commodi vel nisi ut alias excepturi ipsum, totam, labore tempora, odit ex iste tempore sed. Fugit voluptatibus perspiciatis assumenda nulla ad nihil, omnis vel, doloremque sit quam autem optio maiores, illum eius facilis et quo consectetur provident dolor similique! Enim voluptatem dicta expedita veritatis repellat dolorum impedit, provident quasi at.
|
||||
@ -1,20 +0,0 @@
|
||||
---
|
||||
title: "Connect with Us"
|
||||
meta_title: ""
|
||||
description: "this is meta description"
|
||||
draft: false
|
||||
|
||||
#Contact Options
|
||||
contact_meta:
|
||||
- name: "Address"
|
||||
contact: "123 Main Street, Anytown, </br> CA 12335 - USA"
|
||||
|
||||
- name: "Email"
|
||||
contact: "yourmail@domain.com </br> support@domain.com"
|
||||
|
||||
- name: "Phone"
|
||||
contact: "Mobile: (08) 123 456 789 </br> Hotline: 1009 678 456"
|
||||
|
||||
- name: "Shop Time"
|
||||
contact: "Available at 10am-8pm </br>"
|
||||
---
|
||||
@ -1,59 +0,0 @@
|
||||
---
|
||||
title: "Privacy Policy"
|
||||
meta_title: ""
|
||||
description: "this is meta description"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## This Privacy policy was published on 04 May 2023
|
||||
|
||||
### GDPR Compliance
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
<br/>
|
||||
|
||||
### About Astrofront
|
||||
|
||||
#### Service Provided As
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
<br/>
|
||||
|
||||
#### Company Liability
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
<br/>
|
||||
|
||||
#### When we collect personal data about you
|
||||
|
||||
In order to use our Service, you must meet a number of conditions, including but not limited to:
|
||||
|
||||
- Enhance or improve User experience, our Site, or our Service.
|
||||
- Send emails and updates about Conclude, Process transactions.
|
||||
- Send emails about our Site or respond to inquiries.
|
||||
- Including news and requests for agreement to amended legal documents such as this
|
||||
Privacy Policy and our Terms of Service.
|
||||
<br/>
|
||||
|
||||
#### Why we collect and use personal data
|
||||
|
||||
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
|
||||
|
||||
- Enhance or improve User experience, our Site, or our Service.
|
||||
- Send emails and updates about Conclude, Process transactions.
|
||||
- Send emails about our Site or respond to inquiries.
|
||||
- Including news and requests for agreement to amended legal documents such as this
|
||||
Privacy Policy and our Terms of Service.
|
||||
<br/>
|
||||
|
||||
#### Type of personal data collected
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@ -1,97 +0,0 @@
|
||||
---
|
||||
title: "Terms of Service"
|
||||
meta_title: ""
|
||||
description: "this is meta description"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## Astrofront Solutions License Agreement
|
||||
|
||||
Your Rights Roxboro Lighting Limited trading as Onlinelightshop is a UK based company and complies with UK consumer law including the Distance Selling Regulations. This means that shopping with Onlinelightshop is safe. With 30 years experience in lighting retailing we aim to give a thorough and relable sevice, you’ll get your items delivered and we’ll sort out any problems you have. Our Terms and Conditions do not affect your statutory rights. Distance Selling Regulations give you the right to cancel an order and return any goods that may already have been dispatched, (see Returns for Refund) up to 7 days from receipt. Our returns policy allows 21 days.
|
||||
|
||||
##### [Download our Returns Form here.](#http://localhost:4321/terms-services)
|
||||
|
||||
<br/>
|
||||
|
||||
### Sales terms and conditions
|
||||
|
||||
#### Pricing
|
||||
|
||||
Prices include VAT (Value Added Tax)\*, standard UK rate 20%. VAT does not apply to orders that are to be shipped outside the E.U. or to Tax Free areas such as the Channel Islands. These areas will be priced and billed with VAT deducted. The prices on the site are shown including VAT.
|
||||
|
||||
If you select a currency or a delivery country outside the E.U., prices will be displayed VAT free, for example, an item which costs 19.99 GBP will be shown and billed as 17.00 GBP when it is VAT free.
|
||||
|
||||
While we do our best to ensure prices are up to date and correct, errors may occur and in this case you will be contacted before your order is shipped.
|
||||
<br/>
|
||||
|
||||
#### Payment
|
||||
|
||||
Onlinelightshop accept most major credit cards for online orders. You will be asked to enter your card details and the name and the address of the card holder when you place the order. These details will be checked and we may hold an order until we receive the correct details. You will be contacted if we find the card or address details to be incorrect. We only bill your card when your order is ready to be dispatched.
|
||||
|
||||
For offline orders Onlinelightshop accept (£) Sterling Postal Orders, Personal Cheques, (£) Sterling Bank Drafts and (£) Sterling Bank Transfers. We recommend that you do not send cash through the post. Due to varying Exchange Rates all payments made to CRC must be in UK Pounds (£) Sterling.
|
||||
<br/>
|
||||
|
||||
#### Product Availability
|
||||
|
||||
Onlinelightshop endeavours to carry most of the items displayed on the site in stock. If an item you order is out of stock we will contact you with a due date. You may then wish to cancel the order or wait until the item becomes available.
|
||||
<br/>
|
||||
|
||||
#### Out of Stock Orders
|
||||
|
||||
There are times when items become temporarily out of stock. If you order an 'Out Of Stock' product, we will send out the order to you as soon as the product becomes available again. If we cannot source the item we will contact you to inform you. Your credit card will not be debited until we dispatch the product.
|
||||
|
||||
There are times when items become temporarily out of stock. If you order an 'Out Of Stock' product, we will send out the order to you as soon as the product becomes available again. If we cannot source the item we will contact you to inform you. Your credit card will not be debited until we dispatch the product.
|
||||
<br/>
|
||||
|
||||
#### Accuracy of Information
|
||||
|
||||
We put a lot of time and effort into describing and photographing the products we sell using manufacturer's websites, catalogues, our own photography studio and often our own descriptions of the products. Although we aim to ensure that every picture and description is 100% accurate, mistakes do occur so let us know if you see or read something that isn’t correct. If you have purchased something based on a picture or description on the website which turns out to be incorrect we will be happy to replace or refund the product (unused).
|
||||
<br/>
|
||||
|
||||
#### Changes
|
||||
|
||||
If we decide to change our Terms and Conditions, we will post those changes on this page so that you are always aware of what information we collect, how we use it and under what circumstances we disclose it.
|
||||
<br/>
|
||||
|
||||
#### Site Usage
|
||||
|
||||
Onlinelightshop disclaims damages of any kind, compensatory, direct, indirect or consequential damages, loss of data, income or profit, loss of or damage to property and claims of third parties implied or otherwise relating to use of this site.
|
||||
<br/>
|
||||
|
||||
#### Credit Card Security
|
||||
|
||||
We take online security extremely seriously and have taken several steps to ensure that your payment information is processed confidentially and accurately. We use Sagepay, a world leader in secure online payments.
|
||||
|
||||
Onlinelightshop offer the use of secure servers where information is protected by Secure Sockets Layer (SSL), the industry standard encryption technology. SSL works with Netscape Navigator, Microsoft Internet Explorer, AOL and other browsers. This encryption makes it virtually impossible for unauthorised parties to read any information that you send us.
|
||||
<br/>
|
||||
|
||||
#### Price and Payment
|
||||
|
||||
- The Price of the Goods shall be that stipulated on the Seller’s Website. The Price is NOT INCLUSIVE of VAT. The Price EXCLUDES delivery charges.
|
||||
- The total purchase price, including VAT and delivery charges, if any, will be displayed in the Buyer’s shopping cart prior to confirming the order.
|
||||
- Payment of the Price plus VAT and delivery charges must be made in full before dispatch of the Goods.
|
||||
- Payment must be made by secure credit or debit transactions (please see Privacy Statement).
|
||||
<br/>
|
||||
|
||||
#### Warranty
|
||||
|
||||
- The Seller warrants that the Goods will at the time of dispatch correspond to the description given by the Seller.
|
||||
- After 7 Working days from the date of delivery goods will be repaired or replaced dependant upon the nature of the fault.
|
||||
<br/>
|
||||
|
||||
#### Delivery
|
||||
|
||||
- Goods supplied within the UK will normally be delivered within 3-5 working days of acceptance of order.
|
||||
- Where a specific delivery date has been agreed, and where this delivery date cannot be met, the Buyer will be notified and given the opportunity to agree a new delivery date or cancel the order.
|
||||
- Title and risk in the Goods shall pass to the Buyer upon delivery of the Goods.
|
||||
<br/>
|
||||
|
||||
#### Cancellation and Return
|
||||
|
||||
The Buyer shall inspect the Goods immediately upon receipt and shall notify the Seller via the designated Returns Submission Form within 7 working days of delivery if the Goods are damaged or do not comply with any of the Contract. If the Buyer fails to do so the Buyer shall be deemed to have accepted the Goods.
|
||||
|
||||
Where a claim of defect or damage is made the Seller shall be responsible for the recovery of the Goods from the Buyer. The Buyer shall be entitled to a full refund (including delivery costs) if the Goods are in fact defective.
|
||||
|
||||
The Seller reserves the right to refuse a refund if:
|
||||
|
||||
The Goods have been used.
|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
enable: true
|
||||
title: "Curved Collection for Your
|
||||
Bedroom Get 25% Off"
|
||||
sub_title: "Deal of the Week"
|
||||
image: "/images/call-to-action.png"
|
||||
description: "Subscribe our Newsletter and get all latest information and offers"
|
||||
button:
|
||||
enable: true
|
||||
label: "Shop Now"
|
||||
link: "/products"
|
||||
---
|
||||
@ -1,17 +0,0 @@
|
||||
---
|
||||
payment_methods:
|
||||
- name: "Visa"
|
||||
image_url: "/images/payment/visa.png"
|
||||
- name: "MasterCard"
|
||||
image_url: "/images/payment/mastercard.png"
|
||||
- name: "Express"
|
||||
image_url: "/images/payment/express.png"
|
||||
- name: "Bkash"
|
||||
image_url: "/images/payment/bkash.png"
|
||||
- name: "Nagad"
|
||||
image_url: "/images/payment/nagad.png"
|
||||
- name: "Upay"
|
||||
image_url: "/images/payment/upay.png"
|
||||
|
||||
estimated_delivery: "Est. Delivery between 0 - 3 days"
|
||||
---
|
||||
@ -1,180 +0,0 @@
|
||||
---
|
||||
import TwSizeIndicator from "@/components/TwSizeIndicator.astro";
|
||||
import config from "@/config/config.json";
|
||||
import theme from "@/config/theme.json";
|
||||
import { plainify } from "@/lib/utils/textConverter";
|
||||
import Footer from "@/partials/Footer.astro";
|
||||
import Header from "@/partials/Header.astro";
|
||||
import "@/styles/main.scss";
|
||||
import { AstroFont } from "astro-font";
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
// font families
|
||||
const pf = theme.fonts.font_family.primary;
|
||||
const sf = theme.fonts.font_family.secondary;
|
||||
|
||||
let fontPrimary, fontSecondary;
|
||||
if (theme.fonts.font_family.primary) {
|
||||
fontPrimary = theme.fonts.font_family.primary
|
||||
.replace(/\+/g, " ")
|
||||
.replace(/:[ital,]*[ital@]*[wght@]*[0-9,;.]+/gi, "");
|
||||
}
|
||||
if (theme.fonts.font_family.secondary) {
|
||||
fontSecondary = theme.fonts.font_family.secondary
|
||||
.replace(/\+/g, " ")
|
||||
.replace(/:[ital,]*[ital@]*[wght@]*[0-9,;.]+/gi, "");
|
||||
}
|
||||
|
||||
// types for frontmatters
|
||||
export interface Props {
|
||||
title?: string;
|
||||
meta_title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
noindex?: boolean;
|
||||
canonical?: string;
|
||||
}
|
||||
|
||||
// destructure frontmatter
|
||||
const { title, meta_title, description, image, noindex, canonical } =
|
||||
Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- favicon -->
|
||||
<link rel="shortcut icon" href={config.site.favicon} />
|
||||
<!-- theme meta -->
|
||||
<meta name="theme-name" content="astrofront" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: light)"
|
||||
content="#fff"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
content="#000"
|
||||
/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
|
||||
<!-- google font css -->
|
||||
<AstroFont
|
||||
config={[
|
||||
{
|
||||
src: [],
|
||||
preload: false,
|
||||
display: "swap",
|
||||
name: fontPrimary!,
|
||||
fallback: "sans-serif",
|
||||
cssVariable: "font-primary",
|
||||
googleFontsURL: `https://fonts.googleapis.com/css2?family=${pf}&display=swap`,
|
||||
},
|
||||
{
|
||||
src: [],
|
||||
preload: false,
|
||||
display: "swap",
|
||||
name: fontSecondary!,
|
||||
fallback: "sans-serif",
|
||||
cssVariable: "font-secondary",
|
||||
googleFontsURL: `https://fonts.googleapis.com/css2?family=${sf}&display=swap`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<!-- responsive meta -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=5"
|
||||
/>
|
||||
|
||||
<!-- title -->
|
||||
<title>
|
||||
{plainify(meta_title ? meta_title : title ? title : config.site.title)}
|
||||
</title>
|
||||
|
||||
<!-- canonical url -->
|
||||
{canonical && <link rel="canonical" href={canonical} item-prop="url" />}
|
||||
|
||||
<!-- noindex robots -->
|
||||
{noindex && <meta name="robots" content="noindex,nofollow" />}
|
||||
|
||||
<!-- meta-description -->
|
||||
<meta
|
||||
name="description"
|
||||
content={plainify(
|
||||
description ? description : config.metadata.meta_description
|
||||
)}
|
||||
/>
|
||||
|
||||
<ClientRouter />
|
||||
|
||||
<!-- author from config.json -->
|
||||
<meta name="author" content={config.metadata.meta_author} />
|
||||
|
||||
<!-- og-title -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content={plainify(
|
||||
meta_title ? meta_title : title ? title : config.site.title
|
||||
)}
|
||||
/>
|
||||
|
||||
<!-- og-description -->
|
||||
<meta
|
||||
property="og:description"
|
||||
content={plainify(
|
||||
description ? description : config.metadata.meta_description
|
||||
)}
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${config.site.base_url}/${Astro.url.pathname.replace("/", "")}`}
|
||||
/>
|
||||
|
||||
<!-- twitter-title -->
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={plainify(
|
||||
meta_title ? meta_title : title ? title : config.site.title
|
||||
)}
|
||||
/>
|
||||
|
||||
<!-- twitter-description -->
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={plainify(
|
||||
description ? description : config.metadata.meta_description
|
||||
)}
|
||||
/>
|
||||
|
||||
<!-- og-image -->
|
||||
<meta
|
||||
property="og:image"
|
||||
content={`${config.site.base_url}${
|
||||
image ? image : config.metadata.meta_image
|
||||
}`}
|
||||
/>
|
||||
|
||||
<!-- twitter-image -->
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={`${config.site.base_url}${
|
||||
image ? image : config.metadata.meta_image
|
||||
}`}
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<TwSizeIndicator />
|
||||
<Header />
|
||||
<main id="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@ -1,32 +0,0 @@
|
||||
---
|
||||
import { plainify } from "@/lib/utils/textConverter";
|
||||
import ImageMod from "./ImageMod.astro";
|
||||
import Social from "./Social.astro";
|
||||
|
||||
const { data } = Astro.props;
|
||||
const { title, image, social } = data.data;
|
||||
---
|
||||
|
||||
<div
|
||||
class="rounded bg-theme-light p-8 text-center dark:bg-darkmode-theme-light"
|
||||
>
|
||||
{
|
||||
image && (
|
||||
<ImageMod
|
||||
class="mx-auto mb-6 rounded"
|
||||
src={image}
|
||||
alt={title}
|
||||
width={120}
|
||||
height={120}
|
||||
format="webp"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<h4 class="mb-3">
|
||||
<a href={`/authors/${data.slug}`}>{title}</a>
|
||||
</h4>
|
||||
<p class="mb-4">
|
||||
{plainify(data.body?.slice(0, 100))}
|
||||
</p>
|
||||
<Social source={social} className="social-icons" />
|
||||
</div>
|
||||
@ -1,43 +0,0 @@
|
||||
---
|
||||
import { humanize } from "@/lib/utils/textConverter";
|
||||
|
||||
const { className }: { className?: string } = Astro.props;
|
||||
|
||||
const paths = Astro.url.pathname.split("/").filter((x) => x);
|
||||
let parts = [
|
||||
{
|
||||
label: "Home",
|
||||
href: "/",
|
||||
"aria-label": Astro.url.pathname === "/" ? "page" : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
paths.forEach((label: string, i: number) => {
|
||||
const href = `/${paths.slice(0, i + 1).join("/")}`;
|
||||
label !== "page" &&
|
||||
parts.push({
|
||||
label: humanize(label.replace(".html", "").replace(/[-_]/g, " ")) || "",
|
||||
href,
|
||||
"aria-label": Astro.url.pathname === href ? "page" : undefined,
|
||||
});
|
||||
});
|
||||
---
|
||||
|
||||
<nav aria-label="Breadcrumb" class={className}>
|
||||
<ol class="inline-flex" role="list">
|
||||
{
|
||||
parts.map(({ label, ...attrs }, index) => (
|
||||
<li class="mx-1 capitalize" role="listitem">
|
||||
{index > 0 && <span class="inlin-block mr-1">/</span>}
|
||||
{index !== parts.length - 1 ? (
|
||||
<a class="text-primary dark:text-darkmode-primary" {...attrs}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span class="text-light dark:text-darkmode-light">{label}</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
@ -1,81 +0,0 @@
|
||||
---
|
||||
import config from "@/config/config.json";
|
||||
import { AddToCart } from "@/functional-components/cart/AddToCart";
|
||||
import type { Product } from "@/lib/shopify/types";
|
||||
|
||||
interface Props {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
const { products } = Astro.props;
|
||||
const { currencySymbol } = config.shopify;
|
||||
---
|
||||
|
||||
<div class="row">
|
||||
{
|
||||
products.map((product: any) => {
|
||||
const {
|
||||
title,
|
||||
handle,
|
||||
featuredImage,
|
||||
priceRange,
|
||||
variants,
|
||||
compareAtPriceRange,
|
||||
} = product;
|
||||
|
||||
const defaultVariantId = variants.length > 0 ? variants[0].id : undefined;
|
||||
return (
|
||||
<div class="text-center col-6 md:col-4 lg:col-3 mb-8 md:mb-14 group relative">
|
||||
<div class="relative overflow-hidden">
|
||||
<img
|
||||
src={featuredImage.url || "/images/product_image404.jpg"}
|
||||
width={312}
|
||||
height={269}
|
||||
alt={featuredImage.altText || "fallback image"}
|
||||
class="w-[312px] h-[150px] md:h-[269px] object-cover border rounded-md"
|
||||
/>
|
||||
|
||||
<AddToCart
|
||||
client:only="react"
|
||||
variants={product.variants}
|
||||
availableForSale={product.availableForSale}
|
||||
handle={handle}
|
||||
defaultVariantId={defaultVariantId}
|
||||
stylesClass="btn btn-primary max-md:btn-sm z-10 absolute bottom-12 md:bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full md:group-hover:-translate-y-6 duration-300 ease-in-out whitespace-nowrap drop-shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="py-2 md:py-4 text-center z-20">
|
||||
<h2 class="font-medium text-base md:text-xl">
|
||||
<a
|
||||
class="after:absolute after:inset-0"
|
||||
href={`/products/${handle}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</h2>
|
||||
<div class="flex flex-wrap justify-center items-center gap-x-2 mt-2 md:mt-4">
|
||||
<span class="text-base md:text-xl font-bold text-dark dark:text-darkmode-dark">
|
||||
{currencySymbol}
|
||||
{priceRange.minVariantPrice.amount}
|
||||
{compareAtPriceRange?.maxVariantPrice?.currencyCode}
|
||||
</span>
|
||||
|
||||
{parseFloat(compareAtPriceRange?.maxVariantPrice.amount) > 0 && (
|
||||
<s class="text-light dark:text-darkmode-light text-xs md:text-base font-medium">
|
||||
{currencySymbol} {compareAtPriceRange?.maxVariantPrice.amount}{" "}
|
||||
{compareAtPriceRange?.maxVariantPrice?.currencyCode}
|
||||
</s>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<a class="btn btn-sm md:btn-lg btn-primary font-medium" href="/products">
|
||||
+ See All Products
|
||||
</a>
|
||||
</div>
|
||||
@ -1,60 +0,0 @@
|
||||
---
|
||||
import type { ImageMetadata } from "astro";
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
// Props interface for the component
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
loading?: "eager" | "lazy" | null | undefined;
|
||||
decoding?: "async" | "auto" | "sync" | null | undefined;
|
||||
format?: "auto" | "avif" | "jpeg" | "png" | "svg" | "webp";
|
||||
class?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
// Destructuring Astro.props to get the component's props
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
loading,
|
||||
decoding,
|
||||
class: className,
|
||||
format,
|
||||
style,
|
||||
} = Astro.props;
|
||||
|
||||
src = `/public${src}`;
|
||||
|
||||
// Glob pattern to load images from the /public/images folder
|
||||
const images = import.meta.glob("/public/images/**/*.{jpeg,jpg,png,gif}");
|
||||
|
||||
// Check if the source path is valid
|
||||
const isValidPath = images[src] ? true : false;
|
||||
|
||||
// Log a warning message in red if the image is not found
|
||||
!isValidPath &&
|
||||
console.error(
|
||||
`\x1b[31mImage not found - ${src}.\x1b[0m Make sure the image is in the /public/images folder.`,
|
||||
);
|
||||
---
|
||||
|
||||
{
|
||||
isValidPath && (
|
||||
<Image
|
||||
src={images[src]() as Promise<{ default: ImageMetadata }>}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
class={className}
|
||||
format={format}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
---
|
||||
import config from "@/config/config.json";
|
||||
import ImageMod from "./ImageMod.astro";
|
||||
|
||||
const { src, srcDarkmode }: { src?: string; srcDarkmode?: string } =
|
||||
Astro.props;
|
||||
const {
|
||||
logo,
|
||||
logo_darkmode,
|
||||
logo_width,
|
||||
logo_height,
|
||||
logo_text,
|
||||
title,
|
||||
}: {
|
||||
logo: string;
|
||||
logo_darkmode: string;
|
||||
logo_width: any;
|
||||
logo_height: any;
|
||||
logo_text: string;
|
||||
title: string;
|
||||
} = config.site;
|
||||
|
||||
const { theme_switcher }: { theme_switcher: boolean } = config.settings;
|
||||
---
|
||||
|
||||
<a href="/" class="navbar-brand inline-block">
|
||||
{
|
||||
src || srcDarkmode || logo || logo_darkmode ? (
|
||||
<>
|
||||
<ImageMod
|
||||
src={src ? src : logo}
|
||||
class={`inline-block ${theme_switcher && "dark:hidden"}`}
|
||||
width={logo_width.replace("px", "") * 2}
|
||||
height={logo_height.replace("px", "") * 2}
|
||||
alt={title}
|
||||
style={{
|
||||
height: logo_height.replace("px", "") + "px",
|
||||
width: logo_width.replace("px", "") + "px",
|
||||
}}
|
||||
format="webp"
|
||||
/>
|
||||
{theme_switcher && (
|
||||
<ImageMod
|
||||
src={srcDarkmode ? srcDarkmode : logo_darkmode}
|
||||
class={"hidden dark:inline-block"}
|
||||
width={logo_width.replace("px", "") * 2}
|
||||
height={logo_height.replace("px", "") * 2}
|
||||
alt={title}
|
||||
style={{
|
||||
height: logo_height.replace("px", "") + "px",
|
||||
width: logo_width.replace("px", "") + "px",
|
||||
}}
|
||||
format="webp"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : logo_text ? (
|
||||
logo_text
|
||||
) : (
|
||||
title
|
||||
)
|
||||
}
|
||||
</a>
|
||||
@ -1,134 +0,0 @@
|
||||
---
|
||||
type Pagination = {
|
||||
section?: string;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
const { section, currentPage = 1, totalPages = 1 }: Pagination = Astro.props;
|
||||
|
||||
const indexPageLink = currentPage === 2;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
const hasNextPage = totalPages > currentPage!;
|
||||
|
||||
let pageList: number[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageList.push(i);
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
totalPages > 1 && (
|
||||
<nav
|
||||
class="flex items-center justify-center space-x-3"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{/* previous */}
|
||||
{hasPrevPage ? (
|
||||
<a
|
||||
href={
|
||||
indexPageLink
|
||||
? `${section ? "/" + section : "/"}`
|
||||
: `${section ? "/" + section : ""}/page/${currentPage - 1}`
|
||||
}
|
||||
class="rounded px-2 py-1.5 text-dark hover:bg-theme-light dark:text-darkmode-dark dark:hover:bg-darkmode-theme-light"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
height="30"
|
||||
width="30"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<span class="rounded px-2 py-1.5 text-light">
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
height="30"
|
||||
width="30"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* page index */}
|
||||
{pageList.map((pagination, i) =>
|
||||
pagination === currentPage ? (
|
||||
<span
|
||||
aria-current="page"
|
||||
class="rounded bg-primary px-4 py-2 text-white dark:bg-darkmode-primary dark:text-dark"
|
||||
>
|
||||
{pagination}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={
|
||||
i === 0
|
||||
? `${section ? "/" + section : "/"}`
|
||||
: `${section ? "/" + section : ""}/page/${pagination}`
|
||||
}
|
||||
aria-current="page"
|
||||
class="rounded px-4 py-2 text-dark hover:bg-theme-light dark:text-darkmode-dark dark:hover:bg-darkmode-theme-light"
|
||||
>
|
||||
{pagination}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* next page */}
|
||||
{hasNextPage ? (
|
||||
<a
|
||||
href={`${section ? "/" + section : ""}/page/${currentPage + 1}`}
|
||||
class="rounded px-2 py-1.5 text-dark hover:bg-theme-light dark:text-darkmode-dark dark:hover:bg-darkmode-theme-light"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
height="30"
|
||||
width="30"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<span class="rounded px-2 py-1.5 text-light">
|
||||
<span class="sr-only">Next</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
height="30"
|
||||
width="30"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
---
|
||||
const {
|
||||
amount,
|
||||
className = "",
|
||||
currencyCode = "USD",
|
||||
currencyCodeClassName = "",
|
||||
} = Astro.props;
|
||||
|
||||
const formattedAmount = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(parseFloat(amount));
|
||||
|
||||
const combinedClassName =
|
||||
`${className} ${currencyCodeClassName ? "ml-1 inline" : ""}`.trim();
|
||||
---
|
||||
|
||||
<p class={className}>
|
||||
{formattedAmount}
|
||||
<span class={combinedClassName}>{currencyCode}</span>
|
||||
</p>
|
||||
@ -1,61 +0,0 @@
|
||||
---
|
||||
import config from "@/config/config.json";
|
||||
import {
|
||||
IoLogoFacebook,
|
||||
IoLogoLinkedin,
|
||||
IoLogoPinterest,
|
||||
IoLogoTwitter,
|
||||
} from "react-icons/io5";
|
||||
|
||||
const { base_url }: { base_url: string } = config.site;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
slug,
|
||||
className,
|
||||
}: { title?: string; description?: string; slug?: string; className?: string } =
|
||||
Astro.props;
|
||||
---
|
||||
|
||||
<ul class={`${className}`}>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
aria-label="facebook share button"
|
||||
href={`https://facebook.com/sharer/sharer.php?u=${base_url}/${slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<IoLogoFacebook />
|
||||
</a>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
aria-label="twitter share button"
|
||||
href={`https://twitter.com/intent/tweet/?text=${title}&url=${base_url}/${slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<IoLogoTwitter />
|
||||
</a>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
aria-label="linkedin share button"
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${base_url}/${slug}&title=${title}&summary=${description}&source=${base_url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<IoLogoLinkedin />
|
||||
</a>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
aria-label="pinterest share button"
|
||||
href={`https://pinterest.com/pin/create/button/?url=${base_url}/${slug}&media=&description=${description}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<IoLogoPinterest />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@ -1,28 +0,0 @@
|
||||
---
|
||||
const { source, className } = Astro.props;
|
||||
import DynamicIcon from "@/helpers/DynamicIcon";
|
||||
|
||||
export interface ISocial {
|
||||
name: string;
|
||||
icon: string;
|
||||
link: string;
|
||||
}
|
||||
---
|
||||
|
||||
<ul class={className}>
|
||||
{
|
||||
source?.map((social: ISocial) => (
|
||||
<li>
|
||||
<a
|
||||
aria-label={social.name}
|
||||
href={social.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<span class="sr-only">{social.name}</span>
|
||||
<DynamicIcon className="inline-block" icon={social.icon} />
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
@ -1,88 +0,0 @@
|
||||
---
|
||||
import config from "@/config/config.json";
|
||||
|
||||
const { theme_switcher }: { theme_switcher: boolean } = config.settings;
|
||||
const { className }: { className?: string } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
theme_switcher && (
|
||||
<div class={`theme-switcher ${className}`}>
|
||||
<input id="theme-switcher" data-theme-switcher type="checkbox" />
|
||||
<label for="theme-switcher">
|
||||
<span class="sr-only">theme switcher</span>
|
||||
<span>
|
||||
<svg
|
||||
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 opacity-100 dark:opacity-0"
|
||||
viewBox="0 0 56 56"
|
||||
fill="#fff"
|
||||
height="16"
|
||||
width="16"
|
||||
>
|
||||
<path d="M30 4.6c0-1-.9-2-2-2a2 2 0 0 0-2 2v5c0 1 .9 2 2 2s2-1 2-2Zm9.6 9a2 2 0 0 0 0 2.8c.8.8 2 .8 2.9 0L46 13a2 2 0 0 0 0-2.9 2 2 0 0 0-3 0Zm-26 2.8c.7.8 2 .8 2.8 0 .8-.7.8-2 0-2.9L13 10c-.7-.7-2-.8-2.9 0-.7.8-.7 2.1 0 3ZM28 16a12 12 0 0 0-12 12 12 12 0 0 0 12 12 12 12 0 0 0 12-12 12 12 0 0 0-12-12Zm23.3 14c1.1 0 2-.9 2-2s-.9-2-2-2h-4.9a2 2 0 0 0-2 2c0 1.1 1 2 2 2ZM4.7 26a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4.9c1 0 2-.9 2-2s-1-2-2-2Zm37.8 13.6a2 2 0 0 0-3 0 2 2 0 0 0 0 2.9l3.6 3.5a2 2 0 0 0 2.9 0c.8-.8.8-2.1 0-3ZM10 43.1a2 2 0 0 0 0 2.9c.8.7 2.1.8 3 0l3.4-3.5c.8-.8.8-2.1 0-2.9-.8-.8-2-.8-2.9 0Zm20 3.4c0-1.1-.9-2-2-2a2 2 0 0 0-2 2v4.9c0 1 .9 2 2 2s2-1 2-2Z" />
|
||||
</svg>
|
||||
<svg
|
||||
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 opacity-0 dark:opacity-100"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
height="16"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
fill="#000"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.2 2.2c1-.4 2 .6 1.6 1.5-1 3-.4 6.4 1.8 8.7a8.4 8.4 0 0 0 8.7 1.8c1-.3 2 .5 1.5 1.5v.1a10.3 10.3 0 0 1-9.4 6.2A10.3 10.3 0 0 1 3.2 6.7c1-2 2.9-3.5 4.9-4.4Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
import { settings } from "@/config/config.json";
|
||||
const matchMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
matchMedia.addEventListener("change", () =>
|
||||
toggleTheme(document.querySelectorAll("[data-theme-switcher]")),
|
||||
);
|
||||
|
||||
function toggleTheme(themeSwitch: NodeListOf<Element>) {
|
||||
const defaulTheme =
|
||||
settings.default_theme === "system"
|
||||
? matchMedia.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: settings.default_theme;
|
||||
const currentTheme = localStorage.getItem("theme") || defaulTheme;
|
||||
const isDarkTheme = currentTheme === "dark";
|
||||
themeSwitch.forEach((sw: any) => (sw.checked = isDarkTheme));
|
||||
document.documentElement.classList.toggle("dark", isDarkTheme);
|
||||
}
|
||||
|
||||
const setDarkMode = () => {
|
||||
const themeSwitch = document.querySelectorAll("[data-theme-switcher]");
|
||||
toggleTheme(themeSwitch);
|
||||
themeSwitch.forEach((sw) => {
|
||||
sw.addEventListener("click", function () {
|
||||
const defaulTheme =
|
||||
settings.default_theme === "system"
|
||||
? matchMedia.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: settings.default_theme;
|
||||
const currentTheme = localStorage.getItem("theme") || defaulTheme;
|
||||
const newTheme = currentTheme === "light" ? "dark" : "light";
|
||||
localStorage.setItem("theme", newTheme);
|
||||
toggleTheme(themeSwitch);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Runs on initial navigation
|
||||
setDarkMode();
|
||||
// Runs on view transitions navigation
|
||||
document.addEventListener("astro:after-swap", setDarkMode);
|
||||
</script>
|
||||
@ -1,15 +0,0 @@
|
||||
---
|
||||
---
|
||||
|
||||
{
|
||||
process.env.NODE_ENV === "development" && (
|
||||
<div class="fixed left-0 top-0 z-50 flex w-[30px] items-center justify-center bg-gray-200 py-[2.5px] text-[12px] uppercase text-black sm:bg-red-200 md:bg-yellow-200 lg:bg-green-200 xl:bg-blue-200 2xl:bg-pink-200">
|
||||
<span class="block sm:hidden">all</span>
|
||||
<span class="hidden sm:block md:hidden">sm</span>
|
||||
<span class="hidden md:block lg:hidden">md</span>
|
||||
<span class="hidden lg:block xl:hidden">lg</span>
|
||||
<span class="hidden xl:block 2xl:hidden">xl</span>
|
||||
<span class="hidden 2xl:block">2xl</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import type { Faq } from "@/types";
|
||||
import React, { useState } from "react";
|
||||
|
||||
const Accordion = ({ faqs }: { faqs: Faq[] }) => {
|
||||
const [activeTab, setActiveTab] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{faqs.map((faq: Faq, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`accordion ${activeTab === index && "active"}`}
|
||||
>
|
||||
<button
|
||||
className="accordion-header"
|
||||
onClick={() => {
|
||||
activeTab === index ? setActiveTab(null) : setActiveTab(index);
|
||||
}}
|
||||
>
|
||||
{faq.title}
|
||||
<svg
|
||||
className="accordion-icon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 512 512"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M505.755,123.592c-8.341-8.341-21.824-8.341-30.165,0L256.005,343.176L36.421,123.592c-8.341-8.341-21.824-8.341-30.165,0 s-8.341,21.824,0,30.165l234.667,234.667c4.16,4.16,9.621,6.251,15.083,6.251c5.462,0,10.923-2.091,15.083-6.251l234.667-234.667 C514.096,145.416,514.096,131.933,505.755,123.592z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="accordion-content">{faq.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
||||
@ -1,117 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
HiOutlineArrowNarrowLeft,
|
||||
HiOutlineArrowNarrowRight,
|
||||
} from "react-icons/hi";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import { Navigation, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import SkeletonCategory from "./loadings/skeleton/SkeletonCategory";
|
||||
|
||||
const CollectionsSlider = ({ collections }: { collections: any }) => {
|
||||
const [_, setInit] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [collectionsData, setCollectionsData] = useState([]);
|
||||
const [loadingCollectionsData, setLoadingCollectionsData] = useState(true);
|
||||
|
||||
const prevRef = useRef(null);
|
||||
const nextRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCollectionsData(collections);
|
||||
setLoadingCollectionsData(false);
|
||||
}, [collections]);
|
||||
|
||||
if (loadingCollectionsData) {
|
||||
return <SkeletonCategory />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Swiper
|
||||
modules={[Pagination, Navigation]}
|
||||
// navigation={true}
|
||||
slidesPerView={2}
|
||||
spaceBetween={10}
|
||||
breakpoints={{
|
||||
640: {
|
||||
slidesPerView: 2,
|
||||
spaceBetween: 20,
|
||||
},
|
||||
768: {
|
||||
slidesPerView: 3,
|
||||
spaceBetween: 24,
|
||||
},
|
||||
1024: {
|
||||
slidesPerView: 3,
|
||||
spaceBetween: 24,
|
||||
},
|
||||
}}
|
||||
navigation={{
|
||||
prevEl: prevRef.current,
|
||||
nextEl: nextRef.current,
|
||||
}}
|
||||
//trigger a re-render by updating the state on swiper initialization
|
||||
onInit={() => setInit(true)}
|
||||
>
|
||||
{collectionsData?.map((item: any) => {
|
||||
const { title, handle, image, } = item;
|
||||
return (
|
||||
<SwiperSlide key={handle}>
|
||||
<div className="text-center relative">
|
||||
<img
|
||||
src={image?.url}
|
||||
width={424}
|
||||
height={306}
|
||||
alt={title}
|
||||
className="h-[150px] md:h-[250px] lg:h-[306px] object-cover rounded-md"
|
||||
/>
|
||||
<div className="py-6">
|
||||
<h3 className="mb-2 font-medium h4">
|
||||
<a
|
||||
className="after:absolute after:inset-0"
|
||||
href={`/products?c=${handle}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="text-light dark:text-darkmode-light text-xs md:text-xl">
|
||||
{item.products?.edges.length} items
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className={`hidden md:block w-full absolute top-[33%] z-10 px-4 text-dark ${isHovered
|
||||
? "opacity-100 transition-opacity duration-300 ease-in-out"
|
||||
: "opacity-0 transition-opacity duration-300 ease-in-out"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={prevRef}
|
||||
className="p-2 lg:p-3 rounded-md bg-body cursor-pointer shadow-sm absolute left-4"
|
||||
>
|
||||
<HiOutlineArrowNarrowLeft size={24} />
|
||||
</div>
|
||||
<div
|
||||
ref={nextRef}
|
||||
className="p-2 lg:p-3 rounded-md bg-body cursor-pointer shadow-sm absolute right-4"
|
||||
>
|
||||
<HiOutlineArrowNarrowRight size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionsSlider;
|
||||
@ -1,64 +0,0 @@
|
||||
import type { Product } from "@/lib/shopify/types";
|
||||
import React from "react";
|
||||
import "swiper/css";
|
||||
import "swiper/css/pagination";
|
||||
import { Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
|
||||
const HeroSlider = ({ products }: { products: Product[] }) => {
|
||||
return (
|
||||
<>
|
||||
<Swiper
|
||||
pagination={{
|
||||
clickable: true,
|
||||
bulletClass: "banner-pagination-bullet",
|
||||
bulletActiveClass: "banner-pagination-bullet-active",
|
||||
}}
|
||||
modules={[Pagination]}
|
||||
>
|
||||
{products?.map((item: Product) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<div className="row items-center px-7 xl:px-16">
|
||||
<div className="sm:col-12 lg:col-6 order-2 lg:order-0">
|
||||
<div className="text-center py-10 lg:py-0">
|
||||
{item?.description && (
|
||||
<p className="mb-2 lg:mb-3 text-light dark:text-darkmode-light font-medium md:text-xl">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="row">
|
||||
<h1 className="mb-4 lg:mb-10 col-10 sm:col-8 lg:col-12 mx-auto">
|
||||
{item.title}
|
||||
</h1>
|
||||
</div>
|
||||
{item.handle && (
|
||||
<a
|
||||
className="btn btn-sm md:btn-lg btn-primary font-medium"
|
||||
href={`products/${item.handle}`}
|
||||
>
|
||||
Shop Now
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-12 lg:col-6">
|
||||
{item.featuredImage && (
|
||||
<img
|
||||
src={item.featuredImage.url}
|
||||
className="mx-auto w-[388px] lg:w-full"
|
||||
width={"507"}
|
||||
height={"385"}
|
||||
alt="banner image"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSlider;
|
||||
@ -1,103 +0,0 @@
|
||||
import { getUserDetails } from "@/lib/shopify";
|
||||
import type { user } from "@/lib/shopify/types";
|
||||
import Cookies from "js-cookie";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Gravatar from "react-gravatar";
|
||||
import { BsPerson } from "react-icons/bs";
|
||||
|
||||
export const fetchUser = async () => {
|
||||
try {
|
||||
const accessToken = Cookies.get("token");
|
||||
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
} else {
|
||||
const userDetails: user = await getUserDetails(accessToken);
|
||||
const userInfo = userDetails.customer;
|
||||
return userInfo;
|
||||
}
|
||||
} catch (error) {
|
||||
// console.log("Error fetching user details:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const NavUser = ({ pathname }: { pathname: string }) => {
|
||||
const [user, setUser] = useState<any>();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getUser = async () => {
|
||||
const userInfo = await fetchUser();
|
||||
setUser(userInfo);
|
||||
};
|
||||
getUser();
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove("token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setDropdownOpen(!dropdownOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{user ? (
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="relative cursor-pointer text-left sm:text-xs flex items-center justify-center"
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="h-6 w-6 border border-darkmode-border dark:border-border rounded-full">
|
||||
<Gravatar
|
||||
email={user?.email}
|
||||
style={{ borderRadius: "50px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="leading-none max-md:hidden">
|
||||
<div className="flex items-center">
|
||||
<p className="block text-dark dark:text-darkmode-dark text-base truncate">
|
||||
{user?.firstName}
|
||||
</p>
|
||||
<svg
|
||||
className={`w-5 text-dark dark:text-darkmode-dark dark:hover:text-darkmode-primary`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className="text-xl text-dark hover:text-primary dark:border-darkmode-border dark:text-white flex items-center"
|
||||
href="/login"
|
||||
aria-label="login"
|
||||
>
|
||||
<BsPerson className="dark:hover:text-darkmode-primary" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="z-20 text-center absolute w-full bg-white shadow-md rounded mt-2">
|
||||
<button onClick={handleLogout} className="btn btn-primary max-md:btn-sm mt-2">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavUser;
|
||||
@ -1,34 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface PriceProps {
|
||||
amount: string;
|
||||
className?: string;
|
||||
currencyCode?: string;
|
||||
currencyCodeClassName?: string;
|
||||
}
|
||||
|
||||
const Price: React.FC<PriceProps> = ({
|
||||
amount,
|
||||
className = "",
|
||||
currencyCode = "USD",
|
||||
currencyCodeClassName = "",
|
||||
}) => {
|
||||
const formattedAmount = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(parseFloat(amount));
|
||||
|
||||
const combinedClassName = `${className} ${
|
||||
currencyCodeClassName ? "ml-1 inline" : ""
|
||||
}`.trim();
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{formattedAmount}
|
||||
<span className={combinedClassName}>{currencyCode}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default Price;
|
||||
@ -1,156 +0,0 @@
|
||||
import type { ShopifyCollection } from "@/lib/shopify/types";
|
||||
import { slugify } from "@/lib/utils/textConverter";
|
||||
import React, { useState } from "react";
|
||||
import { BsCheckLg } from "react-icons/bs";
|
||||
import ShowTags from "./product/ShowTags";
|
||||
import RangeSlider from "./rangeSlider/RangeSlider";
|
||||
|
||||
const ProductFilters = ({
|
||||
categories,
|
||||
vendors,
|
||||
tags,
|
||||
maxPriceData,
|
||||
vendorsWithCounts,
|
||||
categoriesWithCounts,
|
||||
}: {
|
||||
categories: ShopifyCollection[];
|
||||
vendors: { vendor: string; productCount: number }[];
|
||||
tags: string[];
|
||||
maxPriceData: { amount: string; currencyCode: string };
|
||||
vendorsWithCounts: { vendor: string; productCount: number }[];
|
||||
categoriesWithCounts: { category: string; productCount: number }[];
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useState(
|
||||
new URLSearchParams(window.location.search)
|
||||
);
|
||||
|
||||
const selectedBrands = searchParams.getAll("b");
|
||||
const selectedCategory = searchParams.get("c");
|
||||
|
||||
const updateSearchParams = (newParams: URLSearchParams) => {
|
||||
const newUrl = `${window.location.pathname}?${newParams.toString()}`;
|
||||
window.location.href = newUrl.toString();
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleBrandClick = (name: string) => {
|
||||
const slugName = slugify(name.toLowerCase());
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
const currentBrands = newParams.getAll("b");
|
||||
|
||||
if (currentBrands.includes(slugName)) {
|
||||
newParams.delete("b", slugName);
|
||||
} else {
|
||||
newParams.append("b", slugName);
|
||||
}
|
||||
updateSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (handle: string) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (handle === selectedCategory) {
|
||||
newParams.delete("c");
|
||||
} else {
|
||||
newParams.set("c", handle);
|
||||
}
|
||||
updateSearchParams(newParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h5 className="mb-2 lg:text-xl">Select Price Range</h5>
|
||||
<hr className="dark:border-darkmode-border" />
|
||||
<div className="pt-4">
|
||||
<RangeSlider maxPriceData={maxPriceData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="mb-2 mt-4 lg:mt-6 lg:text-xl">Product Categories</h5>
|
||||
<hr className="dark:border-darkmode-border" />
|
||||
<ul className="mt-4 space-y-4">
|
||||
{categories.map((category) => (
|
||||
<li
|
||||
key={category.handle}
|
||||
className={`flex items-center justify-between cursor-pointer ${selectedCategory === category.handle
|
||||
? "text-dark dark:text-darkmode-dark font-semibold"
|
||||
: "text-light dark:text-darkmode-light"
|
||||
}`}
|
||||
onClick={() => handleCategoryClick(category.handle)}
|
||||
>
|
||||
{category.title}
|
||||
{searchParams.has("c") && !searchParams.has("b") ? (
|
||||
<span>({category?.products?.edges.length || 0})</span>
|
||||
) : (
|
||||
<span>
|
||||
{categoriesWithCounts.length > 0
|
||||
? `(${categoriesWithCounts.find(
|
||||
(c) => c.category === category.title
|
||||
)?.productCount || 0
|
||||
})`
|
||||
: `(${category?.products?.edges.length || 0})`}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{vendors && (
|
||||
<div>
|
||||
<h5 className="mb-2 mt-8 lg:mt-10 lg:text-xl">Brands</h5>
|
||||
<hr className="dark:border-darkmode-border" />
|
||||
<ul className="mt-4 space-y-4">
|
||||
{vendors.map((vendor) => (
|
||||
<li
|
||||
key={vendor.vendor}
|
||||
className={`flex items-center justify-between cursor-pointer text-light dark:text-darkmode-light`}
|
||||
onClick={() => handleBrandClick(vendor.vendor)}
|
||||
>
|
||||
{searchParams.has("b") &&
|
||||
!searchParams.has("c") &&
|
||||
!searchParams.has("minPrice") &&
|
||||
!searchParams.has("maxPrice") &&
|
||||
!searchParams.has("q") &&
|
||||
!searchParams.has("t") ? (
|
||||
<span>
|
||||
{vendor.vendor} ({vendor.productCount})
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{vendorsWithCounts.length > 0
|
||||
? `${vendor.vendor} (${vendorsWithCounts.find(
|
||||
(v) => v.vendor === vendor.vendor
|
||||
)?.productCount || 0
|
||||
})`
|
||||
: `${vendor.vendor} (${vendor.productCount})`}
|
||||
</span>
|
||||
)}
|
||||
<div className="h-4 w-4 rounded-sm flex items-center justify-center border border-light dark:border-darkmode-light">
|
||||
{selectedBrands.includes(slugify(vendor.vendor.toLowerCase())) && (
|
||||
<BsCheckLg size={16} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<h5 className="mb-2 mt-8 lg:mt-10 lg:text-xl">Tags</h5>
|
||||
<hr className="dark:border-darkmode-border" />
|
||||
<div className="mt-4">
|
||||
<ShowTags tags={tags} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductFilters;
|
||||
@ -1,213 +0,0 @@
|
||||
import config from "@/config/config.json";
|
||||
import { defaultSort, sorting } from "@/lib/constants";
|
||||
import type { PageInfo, Product } from "@/lib/shopify/types";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { BiLoaderAlt } from "react-icons/bi";
|
||||
import { AddToCart } from "./cart/AddToCart";
|
||||
|
||||
const ProductGrid = ({
|
||||
initialProducts,
|
||||
initialPageInfo,
|
||||
sortKey,
|
||||
reverse,
|
||||
searchValue
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
initialPageInfo: PageInfo;
|
||||
sortKey: string;
|
||||
reverse: boolean;
|
||||
searchValue: string | null;
|
||||
}) => {
|
||||
const { currencySymbol } = config.shopify;
|
||||
const [products, setProducts] = useState(initialProducts);
|
||||
const [pageInfo, setPageInfo] = useState(initialPageInfo);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSortKey, setCurrentSortKey] = useState(sortKey);
|
||||
const [currentReverse, setCurrentReverse] = useState(reverse);
|
||||
const [sortChanged, setSortChanged] = useState(false);
|
||||
const loaderRef = useRef(null);
|
||||
|
||||
const getSortParams = (sortKey: string) => {
|
||||
const sortOption = sorting.find((item) => item.slug === sortKey) || defaultSort;
|
||||
return { sortKey: sortOption.sortKey, reverse: sortOption.reverse };
|
||||
};
|
||||
|
||||
const loadMoreProducts = async () => {
|
||||
if (loading || !pageInfo.hasNextPage) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/products.json?cursor=${pageInfo.endCursor || ""}&sortKey=${currentSortKey}&reverse=${currentReverse}`
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch");
|
||||
const { products: newProducts, pageInfo: newPageInfo } = await response.json();
|
||||
|
||||
setProducts((prevProducts) => [...prevProducts, ...newProducts]);
|
||||
setPageInfo(newPageInfo);
|
||||
setSortChanged(false);
|
||||
} catch (error) {
|
||||
console.error("Error loading more products:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStateFromURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const newSortKey = params.get("sortKey") || sortKey;
|
||||
|
||||
const { sortKey: mappedSortKey, reverse: mappedReverse } = getSortParams(newSortKey);
|
||||
|
||||
// Update only if URL params differ from current state
|
||||
if (mappedSortKey !== currentSortKey || mappedReverse !== currentReverse) {
|
||||
setCurrentSortKey(mappedSortKey);
|
||||
setCurrentReverse(mappedReverse);
|
||||
setProducts([]);
|
||||
setPageInfo(initialPageInfo);
|
||||
setSortChanged(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for URL changes and handle state updates
|
||||
window.addEventListener("popstate", updateStateFromURL);
|
||||
|
||||
// Cleanup event listener on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("popstate", updateStateFromURL);
|
||||
};
|
||||
}, [initialPageInfo]);
|
||||
|
||||
// Intersection observer to trigger loading more products
|
||||
useEffect(() => {
|
||||
if (sortChanged) {
|
||||
loadMoreProducts();
|
||||
} else {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMoreProducts();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loaderRef.current) {
|
||||
observer.unobserve(loaderRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [pageInfo?.endCursor, currentSortKey, currentReverse, sortChanged]);
|
||||
|
||||
const resultsText = products.length > 1 ? "results" : "result";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row mx-auto">
|
||||
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? "There are no products that match "
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
{products?.length === 0 && (
|
||||
<div className="mx-auto pt-5 text-center">
|
||||
<img
|
||||
className="mx-auto mb-6"
|
||||
src="/images/no-search-found.png"
|
||||
alt="no-search-found"
|
||||
width={211}
|
||||
height={184}
|
||||
/>
|
||||
<h1 className="h2 mb-4">No Product Found!</h1>
|
||||
<p>
|
||||
We couldn't find what you filtered for. Try filtering again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.map((product, index) => {
|
||||
const defaultVariantId =
|
||||
product?.variants.length > 0 ? product?.variants[0].id : undefined;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-center col-12 sm:col-6 md:col-4 group relative"
|
||||
>
|
||||
<div className="md:relative overflow-hidden">
|
||||
<img
|
||||
src={
|
||||
product.featuredImage?.url || "/images/product_image404.jpg"
|
||||
}
|
||||
width={312}
|
||||
height={269}
|
||||
alt={product.featuredImage?.altText || "fallback image"}
|
||||
className="w-full h-[200px] sm:w-[312px] md:h-[269px] object-cover rounded-md border mx-auto"
|
||||
/>
|
||||
|
||||
<AddToCart
|
||||
variants={product?.variants}
|
||||
availableForSale={product?.availableForSale}
|
||||
handle={product?.handle}
|
||||
defaultVariantId={defaultVariantId}
|
||||
stylesClass={
|
||||
"btn btn-primary max-md:btn-sm z-10 absolute bottom-24 md:bottom-0 left-1/2 transform -translate-x-1/2 md:translate-y-full md:group-hover:-translate-y-6 duration-300 ease-in-out whitespace-nowrap drop-shadow-md"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-2 md:py-4 text-center z-20">
|
||||
<h2 className="font-medium text-base md:text-xl">
|
||||
<a
|
||||
className="after:absolute after:inset-0"
|
||||
href={`/products/${product?.handle}`}
|
||||
>
|
||||
{product?.title}
|
||||
</a>
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center items-center gap-x-2 mt-2 md:mt-4">
|
||||
<span className="text-base md:text-xl font-bold text-dark dark:text-darkmode-dark">
|
||||
{currencySymbol}{" "}
|
||||
{product?.priceRange?.minVariantPrice?.amount}{" "}
|
||||
{product?.priceRange?.minVariantPrice?.currencyCode}
|
||||
</span>
|
||||
{parseFloat(
|
||||
product?.compareAtPriceRange?.maxVariantPrice?.amount,
|
||||
) > 0 ? (
|
||||
<s className="text-light dark:text-darkmode-light text-xs md:text-base font-medium">
|
||||
{currencySymbol}{" "}
|
||||
{product?.compareAtPriceRange?.maxVariantPrice?.amount}{" "}
|
||||
{
|
||||
product?.compareAtPriceRange?.maxVariantPrice
|
||||
?.currencyCode
|
||||
}
|
||||
</s>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{pageInfo?.hasNextPage && (
|
||||
<div ref={loaderRef} className="text-center py-4 flex justify-center">
|
||||
{loading ? <BiLoaderAlt className={`animate-spin`} size={30} /> : "Scroll for more"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGrid;
|
||||
@ -1,50 +0,0 @@
|
||||
import { layoutView } from '@/cartStore';
|
||||
import type { PageInfo, Product } from '@/lib/shopify/types';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import SkeletonCards from './loadings/skeleton/SkeletonCards';
|
||||
|
||||
const ProductGrid = lazy(() => import('./ProductGrid'));
|
||||
const ProductList = lazy(() => import('./ProductList'));
|
||||
|
||||
const ProductLayoutViews = ({
|
||||
initialProducts,
|
||||
initialPageInfo,
|
||||
sortKey,
|
||||
reverse,
|
||||
searchValue,
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
initialPageInfo: PageInfo;
|
||||
sortKey: string;
|
||||
reverse: boolean;
|
||||
searchValue: string | null;
|
||||
}) => {
|
||||
const layout = useStore(layoutView);
|
||||
|
||||
return (
|
||||
<div className="col-12 lg:col-9">
|
||||
<Suspense fallback={<SkeletonCards />}>
|
||||
{layout === 'list' ? (
|
||||
<ProductList
|
||||
initialProducts={initialProducts}
|
||||
initialPageInfo={initialPageInfo}
|
||||
sortKey={sortKey}
|
||||
reverse={reverse}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
) : (
|
||||
<ProductGrid
|
||||
initialProducts={initialProducts}
|
||||
initialPageInfo={initialPageInfo}
|
||||
sortKey={sortKey}
|
||||
reverse={reverse}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductLayoutViews;
|
||||
@ -1,219 +0,0 @@
|
||||
import config from "@/config/config.json";
|
||||
import { defaultSort, sorting } from "@/lib/constants";
|
||||
import type { PageInfo, Product } from '@/lib/shopify/types';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BiLoaderAlt } from "react-icons/bi";
|
||||
import { AddToCart } from './cart/AddToCart';
|
||||
|
||||
const ProductList = ({
|
||||
initialProducts,
|
||||
initialPageInfo,
|
||||
sortKey,
|
||||
reverse,
|
||||
searchValue
|
||||
}: {
|
||||
initialProducts: Product[];
|
||||
initialPageInfo: PageInfo;
|
||||
sortKey: string;
|
||||
reverse: boolean;
|
||||
searchValue: string | null;
|
||||
}) => {
|
||||
const { currencySymbol } = config.shopify;
|
||||
const [products, setProducts] = useState(initialProducts);
|
||||
const [pageInfo, setPageInfo] = useState(initialPageInfo);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSortKey, setCurrentSortKey] = useState(sortKey);
|
||||
const [currentReverse, setCurrentReverse] = useState(reverse);
|
||||
const [sortChanged, setSortChanged] = useState(false);
|
||||
const loaderRef = useRef(null);
|
||||
|
||||
const getSortParams = (sortKey: string) => {
|
||||
const sortOption = sorting.find((item) => item.slug === sortKey) || defaultSort;
|
||||
return { sortKey: sortOption.sortKey, reverse: sortOption.reverse };
|
||||
};
|
||||
|
||||
const loadMoreProducts = async () => {
|
||||
if (loading || !pageInfo.hasNextPage) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/products.json?cursor=${pageInfo.endCursor || ''}&sortKey=${currentSortKey}&reverse=${currentReverse}`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
const { products: newProducts, pageInfo: newPageInfo } = await response.json();
|
||||
|
||||
setProducts((prevProducts) => [...prevProducts, ...newProducts]);
|
||||
setPageInfo(newPageInfo);
|
||||
setSortChanged(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading more products:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStateFromURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const newSortKey = params.get("sortKey") || sortKey;
|
||||
|
||||
const { sortKey: mappedSortKey, reverse: mappedReverse } = getSortParams(newSortKey);
|
||||
|
||||
// Update only if URL params differ from current state
|
||||
if (mappedSortKey !== currentSortKey || mappedReverse !== currentReverse) {
|
||||
setCurrentSortKey(mappedSortKey);
|
||||
setCurrentReverse(mappedReverse);
|
||||
setProducts([]); // Clear products to load new set based on params
|
||||
setPageInfo(initialPageInfo); // Reset page info
|
||||
setSortChanged(true); // Set the flag to load products based on new sortKey and reverse
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for URL changes and handle state updates
|
||||
window.addEventListener("popstate", updateStateFromURL);
|
||||
|
||||
// Cleanup event listener on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("popstate", updateStateFromURL);
|
||||
};
|
||||
}, [initialPageInfo]);
|
||||
|
||||
// Intersection observer to trigger loading more products
|
||||
useEffect(() => {
|
||||
if (sortChanged) {
|
||||
// Load products if sorting has changed
|
||||
loadMoreProducts();
|
||||
} else {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMoreProducts();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (loaderRef.current) {
|
||||
observer.unobserve(loaderRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [pageInfo?.endCursor, currentSortKey, currentReverse, sortChanged]);
|
||||
|
||||
const resultsText = products.length > 1 ? "results" : "result";
|
||||
|
||||
return (
|
||||
<div className="row mx-auto">
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? "There are no products that match "
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{products?.length === 0 && (
|
||||
<div className="mx-auto pt-5 text-center">
|
||||
<img
|
||||
className="mx-auto mb-6"
|
||||
src="/images/no-search-found.png"
|
||||
alt="no-search-found"
|
||||
width={211}
|
||||
height={184}
|
||||
/>
|
||||
<h1 className="h2 mb-4">No Product Found!</h1>
|
||||
<p>
|
||||
We couldn't find what you filtered for. Try filtering again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-10">
|
||||
{products?.map((product: Product) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
variants,
|
||||
handle,
|
||||
featuredImage,
|
||||
priceRange,
|
||||
description,
|
||||
compareAtPriceRange,
|
||||
} = product;
|
||||
|
||||
const defaultVariantId =
|
||||
variants.length > 0 ? variants[0].id : undefined;
|
||||
|
||||
return (
|
||||
<div className="col-12" key={id}>
|
||||
<div className="row">
|
||||
<div className="col-4">
|
||||
<img
|
||||
src={featuredImage?.url || "/images/product_image404.jpg"}
|
||||
// fallback={'/images/category-1.png'}
|
||||
width={312}
|
||||
height={269}
|
||||
alt={featuredImage?.altText || "fallback image"}
|
||||
className="w-[312px] h-[150px] md:h-[269px] object-cover border dark:border-darkmode-border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-8 py-3 max-md:pt-4">
|
||||
<h2 className="font-bold md:font-normal h4">
|
||||
<a href={`/products/${handle}`}>{title}</a>
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-x-2 mt-2">
|
||||
<span className="text-light dark:text-darkmode-light text-xs md:text-lg font-bold">
|
||||
৳ {priceRange?.minVariantPrice?.amount}{" "}
|
||||
{priceRange?.minVariantPrice?.currencyCode}
|
||||
</span>
|
||||
{parseFloat(
|
||||
compareAtPriceRange?.maxVariantPrice?.amount,
|
||||
) > 0 ? (
|
||||
<s className="text-light dark:text-darkmode-light text-xs md:text-base font-medium">
|
||||
{currencySymbol}{" "}
|
||||
{compareAtPriceRange?.maxVariantPrice?.amount}{" "}
|
||||
{compareAtPriceRange?.maxVariantPrice?.currencyCode}
|
||||
</s>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="max-md:text-xs text-light dark:text-darkmode-light my-4 md:mb-8 line-clamp-1 md:line-clamp-3">
|
||||
{description}
|
||||
</p>
|
||||
<AddToCart
|
||||
variants={product?.variants}
|
||||
availableForSale={product?.availableForSale}
|
||||
handle={product?.handle}
|
||||
defaultVariantId={defaultVariantId}
|
||||
stylesClass={
|
||||
"btn btn-outline-primary max-md:btn-sm drop-shadow-md"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pageInfo?.hasNextPage && (
|
||||
<div ref={loaderRef} className="text-center py-4">
|
||||
{loading ? <BiLoaderAlt className={`animate-spin`} size={30} /> : 'Scroll for more'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductList;
|
||||
@ -1,78 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { IoSearch, IoClose } from "react-icons/io5";
|
||||
|
||||
const SearchBar = () => {
|
||||
const [isInputEditing, setInputEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const query = searchParams.get("q");
|
||||
if (query) {
|
||||
setInputValue(query);
|
||||
setInputEditing(true);
|
||||
}
|
||||
|
||||
const inputField = document.getElementById("searchInput") as HTMLInputElement;
|
||||
if (isInputEditing || query) {
|
||||
inputField.focus();
|
||||
}
|
||||
}, [isInputEditing]);
|
||||
|
||||
const updateURL = (query: string) => {
|
||||
const newURL = query ? `/products?q=${encodeURIComponent(query)}` : '/products';
|
||||
// window.history.pushState({}, '', newURL);
|
||||
window.location.href = newURL.toString();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputEditing(true);
|
||||
setInputValue(e.target.value);
|
||||
|
||||
updateURL(e.target.value);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue("");
|
||||
setInputEditing(false);
|
||||
updateURL("");
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const searchInput = form.search as HTMLInputElement;
|
||||
updateURL(searchInput.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="border border-border dark:border-darkmode-border rounded-full flex bg-light/10 pl-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search for products"
|
||||
autoComplete="off"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
id="searchInput"
|
||||
className="bg-transparent border-none search-input focus:ring-transparent p-2 w-full"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 flex h-full items-center">
|
||||
{inputValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-2 m-1 rounded-full"
|
||||
>
|
||||
<IoClose className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="search-icon p-2 m-1 rounded-full">
|
||||
<IoSearch className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@ -1,167 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { BiLoaderAlt } from "react-icons/bi";
|
||||
|
||||
export interface FormData {
|
||||
firstName?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const SignUpForm = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
firstName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>([]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const form = new FormData();
|
||||
form.append("firstName", formData.firstName || "");
|
||||
form.append("email", formData.email);
|
||||
form.append("password", formData.password);
|
||||
|
||||
const response = await fetch("/api/sign-up", {
|
||||
method: "POST",
|
||||
body: form, // Use FormData
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setErrorMessages([]);
|
||||
localStorage.setItem("user", JSON.stringify(responseData));
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
const errors = responseData.errors || [
|
||||
{ message: "Sign-up failed." },
|
||||
];
|
||||
setErrorMessages(errors.map((error: any) => error.message));
|
||||
}
|
||||
} else {
|
||||
setErrorMessages(["Invalid response from the server."]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during sign-up:", error);
|
||||
setErrorMessages(["An error occurred. Please try again."]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-11 sm:col-9 md:col-7 mx-auto">
|
||||
<div className="mb-14 text-center">
|
||||
<h2 className="max-md:h1 md:mb-2">Create an account</h2>
|
||||
<p className="md:text-lg">Create an account and start using...</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignUp}>
|
||||
<div>
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
name="firstName"
|
||||
className="form-input"
|
||||
placeholder="Enter your name"
|
||||
type="text"
|
||||
onChange={handleChange}
|
||||
value={formData.firstName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label mt-8">Email Address</label>
|
||||
<input
|
||||
name="email"
|
||||
className="form-input"
|
||||
placeholder="Type your email"
|
||||
type="email"
|
||||
onChange={handleChange}
|
||||
value={formData.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label mt-8">Password</label>
|
||||
<input
|
||||
name="password"
|
||||
className="form-input"
|
||||
placeholder="********"
|
||||
type="password"
|
||||
onChange={handleChange}
|
||||
value={formData.password}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessages.length > 0 &&
|
||||
errorMessages.map((error, index) => (
|
||||
<p key={index} className="font-medium text-red-500 mt-2">
|
||||
*{error}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary md:text-lg md:font-medium w-full mt-10"
|
||||
>
|
||||
{loading ? (
|
||||
<BiLoaderAlt className="animate-spin mx-auto" size={26} />
|
||||
) : (
|
||||
"Sign Up"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-x-2 text-sm md:text-base mt-6">
|
||||
<p className="text-light dark:text-darkmode-light">
|
||||
I have read and agree to the
|
||||
</p>
|
||||
<a
|
||||
className="underline font-medium text-dark dark:text-darkmode-dark"
|
||||
href="/terms-services"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2 text-sm md:text-base mt-2">
|
||||
<p className="text-light dark:text-darkmode-light">
|
||||
Have an account?
|
||||
</p>
|
||||
<a
|
||||
className="underline font-medium text-dark dark:text-darkmode-dark"
|
||||
href="/login"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpForm;
|
||||
@ -1,92 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import DynamicIcon from "@/helpers/DynamicIcon";
|
||||
|
||||
const SocialShare: React.FC<{ socialName: string; className: string; pathname: string }> = ({
|
||||
socialName,
|
||||
className,
|
||||
pathname,
|
||||
}) => {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(window.location.origin);
|
||||
}, []);
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const fullLink = `${baseUrl}${window.location.pathname}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullLink);
|
||||
// Show the tooltip
|
||||
setIsTooltipVisible(true);
|
||||
setTimeout(() => {
|
||||
setIsTooltipVisible(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy text: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={className}>
|
||||
<li>
|
||||
<a
|
||||
aria-label={socialName}
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${baseUrl}${pathname}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<span className="sr-only">{socialName}</span>
|
||||
<DynamicIcon className="inline-block" icon={"FaFacebookF"} />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
aria-label={socialName}
|
||||
href={`https://twitter.com/intent/tweet?text=${baseUrl}${pathname}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<span className="sr-only">{socialName}</span>
|
||||
<DynamicIcon className="inline-block" icon={"FaXTwitter"} />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
aria-label={socialName}
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${baseUrl}${pathname}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<span className="sr-only">{socialName}</span>
|
||||
<DynamicIcon className="inline-block" icon={"FaLinkedinIn"} />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
className="cursor-pointer relative"
|
||||
onClick={handleCopyLink}
|
||||
aria-label="Copy Link"
|
||||
>
|
||||
<span className="sr-only">Copy Link</span>
|
||||
{isTooltipVisible && (
|
||||
<span className="text-xs absolute -right-16 text-text dark:text-darkmode-text whitespace-nowrap">
|
||||
<DynamicIcon
|
||||
className="inline-block text-green-500"
|
||||
icon={"FaLink"}
|
||||
/>{" "}
|
||||
copied!
|
||||
</span>
|
||||
)}
|
||||
<DynamicIcon className="inline-block" icon={"FaRegCopy"} />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialShare;
|
||||
@ -1,129 +0,0 @@
|
||||
import { markdownify } from "@/lib/utils/textConverter";
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
HiOutlineArrowNarrowLeft,
|
||||
HiOutlineArrowNarrowRight,
|
||||
} from "react-icons/hi";
|
||||
import type { Testimonial } from "@/types";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import { Navigation, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
|
||||
const Testimonials = ({
|
||||
title,
|
||||
testimonials,
|
||||
}: {
|
||||
title: string;
|
||||
testimonials: Array<Testimonial>;
|
||||
}) => {
|
||||
const [_, setInit] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const prevRef = useRef(null);
|
||||
const nextRef = useRef(null);
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="mx-auto mb-12 text-center md:col-10 lg:col-8 xl:col-6">
|
||||
<h2 dangerouslySetInnerHTML={{ __html: markdownify(title) }} className="mb-4" />
|
||||
{/* <p
|
||||
dangerouslySetInnerHTML={markdownify(
|
||||
data.frontmatter.description!,
|
||||
)}
|
||||
/> */}
|
||||
</div>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Swiper
|
||||
modules={[Pagination, Navigation]}
|
||||
spaceBetween={24}
|
||||
navigation={{
|
||||
prevEl: prevRef.current,
|
||||
nextEl: nextRef.current,
|
||||
}}
|
||||
//trigger a re-render by updating the state on swiper initialization
|
||||
onInit={() => setInit(true)}
|
||||
>
|
||||
{testimonials.map((item: Testimonial, index: number) => (
|
||||
<SwiperSlide key={index}>
|
||||
<div className="rounded-lg relative flex flex-col items-center bg-theme-light px-7 py-10 dark:bg-darkmode-theme-light">
|
||||
<div className="text-dark dark:text-white absolute opacity-25">
|
||||
<svg
|
||||
width="160"
|
||||
height="160"
|
||||
viewBox="0 0 160 160"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M160 110V80H140.156H120.312V78C120.312 71.9375 122.938 64 127.031 57.7812C128.969 54.8125 134.812 48.9688 137.781 47.0312C144 42.9375 151.938 40.3125 158 40.3125H160V30.1562V20H157.25C154.281 20 148.75 20.8125 144.844 21.8438C130.25 25.5937 117 35.3125 109.062 48.0937C104.656 55.2187 102 62.3437 100.594 70.9375C100.062 74.2812 100 76.7188 100 107.281V140H130H160L160 110Z"
|
||||
fill="#D9D9D9"
|
||||
/>
|
||||
<path
|
||||
d="M60 110L60 80H40.1562H20.3125V78C20.3125 71.9375 22.9375 64 27.0312 57.7812C28.9687 54.8125 34.8125 48.9688 37.7812 47.0312C44 42.9375 51.9375 40.3125 58 40.3125H60V30.1562V20H57.25C54.2812 20 48.75 20.8125 44.8438 21.8438C30.25 25.5937 17 35.3125 9.0625 48.0937C4.65625 55.2187 2 62.3437 0.59375 70.9375C0.0625 74.2812 0 76.7188 0 107.281V140H30H60V110Z"
|
||||
fill="#D9D9D9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<blockquote
|
||||
className="mt-14 text-center mx-auto md:col-10 lg:col-8 z-10"
|
||||
dangerouslySetInnerHTML={{ __html: markdownify(item.content) }}
|
||||
/>
|
||||
<div className="mt-11 flex flex-col items-center">
|
||||
<div className="text-dark dark:text-white mb-4">
|
||||
<img
|
||||
height={50}
|
||||
width={50}
|
||||
className="rounded-full"
|
||||
src={item.avatar}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
dangerouslySetInnerHTML={{ __html: markdownify(item.name) }}
|
||||
className="h5 font-primary font-semibold"
|
||||
/>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: markdownify(item.designation) }}
|
||||
className="text-dark dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`hidden lg:flex justify-between w-full absolute top-1/2 z-10 px-6 text-dark ${isHovered
|
||||
? "opacity-100 transition-opacity duration-300 ease-in-out"
|
||||
: "opacity-0 transition-opacity duration-300 ease-in-out"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={prevRef}
|
||||
className="p-2 lg:p-4 rounded-md bg-body cursor-pointer shadow-sm"
|
||||
>
|
||||
<HiOutlineArrowNarrowLeft size={24} />
|
||||
</div>
|
||||
<div
|
||||
ref={nextRef}
|
||||
className="p-2 lg:p-4 rounded-md bg-body cursor-pointer shadow-sm"
|
||||
>
|
||||
<HiOutlineArrowNarrowRight size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Testimonials;
|
||||
@ -1,189 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import type { ProductVariant } from "@/lib/shopify/types";
|
||||
import { BiLoaderAlt } from "react-icons/bi";
|
||||
import { addItemToCart } from "@/cartStore";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
availableForSale: boolean;
|
||||
selectedVariantId: string | undefined;
|
||||
stylesClass: string;
|
||||
handle: string | null;
|
||||
pending: boolean;
|
||||
onClick: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
selectedVariantId,
|
||||
stylesClass,
|
||||
handle,
|
||||
pending,
|
||||
onClick,
|
||||
}: SubmitButtonProps) {
|
||||
const buttonClasses = stylesClass;
|
||||
const disabledClasses = "cursor-not-allowed flex";
|
||||
|
||||
const DynamicTag = handle === null ? "button" : "a";
|
||||
|
||||
if (!availableForSale) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
aria-disabled
|
||||
className={`${buttonClasses} ${disabledClasses}`}
|
||||
>
|
||||
Out Of Stock
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<DynamicTag
|
||||
href={`/products/${handle}`}
|
||||
aria-label="Please select an option"
|
||||
aria-disabled
|
||||
className={`${buttonClasses} ${DynamicTag === "button" ? disabledClasses : ""}`}
|
||||
>
|
||||
Select Variant
|
||||
</DynamicTag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label="Add to cart"
|
||||
aria-disabled={pending ? "true" : "false"}
|
||||
className={`${buttonClasses}`}
|
||||
>
|
||||
{pending ? (
|
||||
<BiLoaderAlt
|
||||
className={`animate-spin w-[70px] md:w-[85px]`}
|
||||
size={26}
|
||||
/>
|
||||
) : (
|
||||
"Add To Cart"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
interface AddToCartProps {
|
||||
variants: ProductVariant[];
|
||||
availableForSale: boolean;
|
||||
stylesClass: string;
|
||||
handle: string | null;
|
||||
defaultVariantId: string | undefined;
|
||||
}
|
||||
export function AddToCart({
|
||||
variants,
|
||||
availableForSale,
|
||||
stylesClass,
|
||||
handle,
|
||||
defaultVariantId,
|
||||
}: AddToCartProps) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>(defaultVariantId);
|
||||
const lastUrl = useRef(window.location.href);
|
||||
|
||||
// Function to update selectedVariantId based on URL
|
||||
const updateSelectedVariantFromUrl = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const selectedOptions = Array.from(searchParams.entries());
|
||||
|
||||
const variant = variants.find((variant) =>
|
||||
selectedOptions.every(([key, value]) =>
|
||||
variant.selectedOptions.some(
|
||||
(option) =>
|
||||
option.name.toLowerCase() === key && option.value === value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setSelectedVariantId(variant?.id || defaultVariantId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update selected variant on mount and whenever the variants change
|
||||
updateSelectedVariantFromUrl();
|
||||
|
||||
// Set up popstate listener for browser navigation
|
||||
const handlePopState = () => {
|
||||
updateSelectedVariantFromUrl();
|
||||
};
|
||||
|
||||
// Set up URL change detection
|
||||
const detectUrlChange = () => {
|
||||
const currentUrl = window.location.href;
|
||||
if (currentUrl !== lastUrl.current) {
|
||||
lastUrl.current = currentUrl;
|
||||
updateSelectedVariantFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up observers
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
// Check for URL changes every 100ms
|
||||
const urlCheckInterval = setInterval(detectUrlChange, 100);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
clearInterval(urlCheckInterval);
|
||||
};
|
||||
}, [variants, defaultVariantId]);
|
||||
|
||||
// Optional: Listen to pushState and replaceState
|
||||
useEffect(() => {
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function (...args) {
|
||||
originalPushState.apply(this, args);
|
||||
updateSelectedVariantFromUrl();
|
||||
};
|
||||
|
||||
history.replaceState = function (...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
updateSelectedVariantFromUrl();
|
||||
};
|
||||
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await addItemToCart(selectedVariantId);
|
||||
setMessage(result);
|
||||
} catch (error: any) {
|
||||
setMessage(error.message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
selectedVariantId={selectedVariantId}
|
||||
stylesClass={stylesClass}
|
||||
handle={handle}
|
||||
pending={pending}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
import CartModal from "./CartModal";
|
||||
|
||||
// let cart;
|
||||
// const cartId = Astro.cookies.get("cartId")?.value;
|
||||
|
||||
// if (cartId) {
|
||||
// cart = await getCart(cartId);
|
||||
// }
|
||||
---
|
||||
|
||||
<CartModal client:load />
|
||||
@ -1,205 +0,0 @@
|
||||
import { cart, refreshCartState, totalQuantity } from "@/cartStore";
|
||||
import { DEFAULT_OPTION } from "@/lib/constants";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FaShoppingCart } from "react-icons/fa";
|
||||
import OpenCart from "./OpenCart";
|
||||
import Price from "../Price";
|
||||
import CloseCart from "./CloseCart";
|
||||
import DeleteItemButton from "./DeleteItemButton";
|
||||
import EditItemQuantityButton from "./EditItemQuantityButton";
|
||||
import { createUrl } from "@/lib/utils";
|
||||
import LoadingDots from "../loadings/LoadingDots";
|
||||
|
||||
const CartModal: React.FC = () => {
|
||||
const currentCart = useStore(cart);
|
||||
const quantity = useStore(totalQuantity);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Refresh the cart when the component mounts
|
||||
useEffect(() => {
|
||||
async function initializeCart() {
|
||||
try {
|
||||
await refreshCartState();
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh cart:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initializeCart(); // Initialize cart on mount
|
||||
}, []);
|
||||
|
||||
// Handlers for opening and closing the cart
|
||||
const openCart = () => {
|
||||
setIsOpen(true);
|
||||
document.body.style.overflow = "hidden"; // Prevent scrolling when the cart is open
|
||||
};
|
||||
|
||||
const closeCart = () => {
|
||||
setIsOpen(false);
|
||||
document.body.style.overflow = ""; // Re-enable scrolling when the cart is closed
|
||||
};
|
||||
|
||||
if (loading) return <LoadingDots className="bg-black dark:bg-white" />;
|
||||
if (!currentCart) return <OpenCart quantity={quantity} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="cursor-pointer" aria-label="Open cart" onClick={openCart}>
|
||||
<OpenCart quantity={quantity} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="cartOverlay"
|
||||
className={`fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity ${isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
onClick={closeCart}
|
||||
></div>
|
||||
|
||||
<div
|
||||
id="cartDialog"
|
||||
className={`fixed inset-y-0 right-0 z-50 w-full md:w-[390px] transform transition-transform duration-300 ease-in-out ${isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-fit flex flex-col border-l border-b drop-shadow-lg rounded-bl-md border-neutral-200 bg-body p-6 text-black dark:border-neutral-700 dark:bg-darkmode-body dark:text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-semibold">Your Cart</p>
|
||||
<button aria-label="Close cart" onClick={closeCart}>
|
||||
<CloseCart />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px absolute bg-dark dark:bg-darkmode-dark left-0 top-16"></div>
|
||||
|
||||
{currentCart.lines.length === 0 ? (
|
||||
<div className="flex flex-col justify-center items-center space-y-6 my-auto">
|
||||
<div className="md:mt-16">
|
||||
<FaShoppingCart size={76} />
|
||||
</div>
|
||||
<p>Oops. Your Bag Is Empty.</p>
|
||||
<a href="/products" className="btn btn-primary w-full">
|
||||
Don't Miss Out: Add Product
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||
<ul className="flex-grow overflow-auto py-4">
|
||||
{currentCart.lines.map((item: any) => {
|
||||
const merchandiseSearchParams: Record<string, string> = {};
|
||||
item.merchandise.selectedOptions.forEach(
|
||||
({ name, value }: any) => {
|
||||
if (value !== DEFAULT_OPTION) {
|
||||
merchandiseSearchParams[name.toLowerCase()] = value;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// const merchandiseUrl = new URL(
|
||||
// `/products/${item.merchandise.product.handle}`,
|
||||
// window.location.origin,
|
||||
// );
|
||||
|
||||
// merchandiseUrl.search = new URLSearchParams(
|
||||
// merchandiseSearchParams,
|
||||
// ).toString();
|
||||
|
||||
const merchandiseUrl = createUrl(
|
||||
`/products/${item.merchandise.product.handle}`,
|
||||
new URLSearchParams(merchandiseSearchParams),
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||
<div className="absolute z-40 -mt-2 ml-[55px]">
|
||||
<DeleteItemButton item={item} />
|
||||
</div>
|
||||
<a
|
||||
href={merchandiseUrl.toString()}
|
||||
className="z-30 flex flex-row space-x-4"
|
||||
>
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300">
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={
|
||||
item.merchandise.product.featuredImage?.url ||
|
||||
"/images/product_image404.jpg"
|
||||
}
|
||||
alt={
|
||||
item.merchandise.product.featuredImage
|
||||
?.altText || item.merchandise.product.title
|
||||
}
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col text-base">
|
||||
<span>{item.merchandise.product.title}</span>
|
||||
{item.merchandise.title !== DEFAULT_OPTION && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
{item.merchandise.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<div className="flex h-16 flex-col justify-between">
|
||||
<Price
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<EditItemQuantityButton item={item} type="minus" />
|
||||
<p>{item.quantity}</p>
|
||||
<EditItemQuantityButton item={item} type="plus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||
<p>Taxes</p>
|
||||
<Price
|
||||
className="text-right text-base text-black dark:text-white"
|
||||
amount={currentCart.cost.totalTaxAmount.amount}
|
||||
currencyCode={currentCart.cost.totalTaxAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||
<p>Shipping</p>
|
||||
<p className="text-right">Calculated at checkout</p>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||
<p>Total</p>
|
||||
<Price
|
||||
className="text-right text-base text-black dark:text-white"
|
||||
amount={currentCart.cost.totalAmount.amount}
|
||||
currencyCode={currentCart.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={currentCart.checkoutUrl}
|
||||
className="block w-full rounded-md bg-dark dark:bg-darkmode-dark p-3 text-center text-sm font-medium text-darkmode-light dark:text-dark opacity-90 hover:opacity-100"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartModal;
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import { FaXmark } from "react-icons/fa6";
|
||||
|
||||
export default function CloseCart({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="relative text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<FaXmark
|
||||
className={`h-6 transition-all ease-in-out hover:scale-110 ${className || ""}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaXmark } from "react-icons/fa6";
|
||||
import { removeItemFromCart, refreshCartState } from "@/cartStore";
|
||||
import LoadingDots from "../loadings/LoadingDots";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
onClick: () => void;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
const SubmitButton: React.FC<SubmitButtonProps> = ({ onClick, pending }) => (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
aria-label="Remove cart item"
|
||||
aria-disabled={pending}
|
||||
className={`ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200 ${pending ? "cursor-not-allowed px-0" : ""
|
||||
}`}
|
||||
>
|
||||
{pending ? (
|
||||
<LoadingDots className="bg-white" />
|
||||
) : (
|
||||
<FaXmark className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
interface DeleteItemButtonProps {
|
||||
item: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteItemButton: React.FC<DeleteItemButtonProps> = ({ item }) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
|
||||
try {
|
||||
await removeItemFromCart(item.id);
|
||||
await refreshCartState();
|
||||
setMessage("Item removed");
|
||||
} catch (error) {
|
||||
console.error("Error removing item:", error);
|
||||
setMessage("Error removing item");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<SubmitButton onClick={() => !pending} pending={pending} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteItemButton;
|
||||
@ -1,68 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaMinus, FaPlus } from "react-icons/fa6";
|
||||
import { updateCartItemQuantity, refreshCartState } from "@/cartStore";
|
||||
import type { CartItem } from "@/lib/shopify/types";
|
||||
import LoadingDots from "../loadings/LoadingDots";
|
||||
|
||||
interface Props {
|
||||
item: CartItem;
|
||||
type: "plus" | "minus";
|
||||
}
|
||||
|
||||
const EditItemQuantityButton: React.FC<Props> = ({ item, type }) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newQuantity = type === "plus" ? item.quantity + 1 : item.quantity - 1;
|
||||
if (newQuantity < 1) return;
|
||||
|
||||
setPending(true);
|
||||
|
||||
try {
|
||||
await updateCartItemQuantity({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: newQuantity,
|
||||
});
|
||||
|
||||
await refreshCartState();
|
||||
setMessage("Quantity updated");
|
||||
} catch (error) {
|
||||
console.error("Error updating item quantity:", error);
|
||||
setMessage("Failed to update quantity");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={
|
||||
type === "plus" ? "Increase item quantity" : "Reduce item quantity"
|
||||
}
|
||||
aria-disabled={pending}
|
||||
disabled={pending}
|
||||
className={`ease flex h-full min-w-[36px] max-w-[36px] items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80 ${type === "minus" ? "ml-auto" : ""
|
||||
} ${pending ? "cursor-not-allowed opacity-50" : ""}`}
|
||||
>
|
||||
{pending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === "plus" ? (
|
||||
<FaPlus className="h-4 w-4 dark:text-neutral-500" />
|
||||
) : (
|
||||
<FaMinus className="h-4 w-4 dark:text-neutral-500" />
|
||||
)}
|
||||
</button>
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditItemQuantityButton;
|
||||
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
import { BsCart3 } from "react-icons/bs";
|
||||
|
||||
interface OpenCartProps {
|
||||
className?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
const OpenCart: React.FC<OpenCartProps> = ({ className = "", quantity }) => {
|
||||
return (
|
||||
<div className="relative text-xl text-dark hover:text-primary dark:border-darkmode-border dark:text-white">
|
||||
<BsCart3 className={`dark:hover:text-darkmode-primary ${className}`} />
|
||||
|
||||
{quantity ? (
|
||||
<div className="bg-black text-white dark:bg-white dark:text-black text-xs rounded-full p-1 absolute -top-1 md:-top-2 -right-3 md:-right-4 w-5 h-5 flex items-center justify-center">
|
||||
{quantity}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenCart;
|
||||
@ -1,85 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FilterDropdownItem } from "./FilterDropdownItem";
|
||||
import type { ListItem } from "../product/ProductLayouts";
|
||||
|
||||
|
||||
const DropdownMenu = ({ list }: { list: ListItem[] }) => {
|
||||
const [active, setActive] = useState<string>("");
|
||||
const [openSelect, setOpenSelect] = useState<boolean>(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
return () => window.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
list.forEach((listItem) => {
|
||||
if (
|
||||
("path" in listItem && currentPath === listItem.path) ||
|
||||
("slug" in listItem && searchParams.get("sort") === listItem.slug)
|
||||
) {
|
||||
setActive(listItem.title);
|
||||
}
|
||||
});
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left text-light" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-light/10"
|
||||
onClick={() => {
|
||||
setOpenSelect(!openSelect);
|
||||
}}
|
||||
id="menu-button"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div>{active}</div>
|
||||
<svg
|
||||
className={`-mr-1 h-5 w-5 text-gray-400 transform ${openSelect ? "rotate-180" : ""
|
||||
} transition-transform`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{openSelect && (
|
||||
<div
|
||||
className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden"
|
||||
onClick={() => {
|
||||
setOpenSelect(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
>
|
||||
{list.map((item, i) => (
|
||||
<FilterDropdownItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
||||
@ -1,75 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createUrl } from "@/lib/utils";
|
||||
|
||||
function PathFilterItem({ item }: { item: any }) {
|
||||
const [pathname, setPathname] = useState("");
|
||||
const [searchParams, setSearchParams] = useState(new URLSearchParams());
|
||||
|
||||
useEffect(() => {
|
||||
setPathname(window.location.pathname);
|
||||
setSearchParams(new URLSearchParams(window.location.search));
|
||||
}, []);
|
||||
|
||||
const active = pathname === item.path;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
const DynamicTag = active ? "p" : "a";
|
||||
|
||||
newParams.delete("q");
|
||||
|
||||
return (
|
||||
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
|
||||
<DynamicTag
|
||||
href={createUrl(item.path, newParams)}
|
||||
className={`w-full text-sm ${active ? "bg-green-400" : "hover:underline"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SortFilterItem({ item }: { item: any }) {
|
||||
const [pathname, setPathname] = useState("");
|
||||
const [searchParams, setSearchParams] = useState(new URLSearchParams());
|
||||
|
||||
useEffect(() => {
|
||||
setPathname(window.location.pathname);
|
||||
setSearchParams(new URLSearchParams(window.location.search));
|
||||
}, []);
|
||||
|
||||
// const q = searchParams.get("q");
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (item.slug) {
|
||||
newParams.set("sort", item.slug);
|
||||
} else {
|
||||
newParams.delete("sort");
|
||||
}
|
||||
|
||||
const href = createUrl(pathname, newParams);
|
||||
const active = searchParams.get("sort") === item.slug;
|
||||
const DynamicTag = active ? "p" : "a";
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex text-sm text-dark hover:bg-light hover:text-white"
|
||||
key={item.title}
|
||||
>
|
||||
<DynamicTag
|
||||
href={href}
|
||||
className={`w-full pl-4 py-2 ${active ? "bg-dark text-white" : ""}`}
|
||||
>
|
||||
{item.title}
|
||||
</DynamicTag>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterDropdownItem({ item }: { item: any }) {
|
||||
return "path" in item ? (
|
||||
<PathFilterItem item={item} />
|
||||
) : (
|
||||
<SortFilterItem item={item} />
|
||||
);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md";
|
||||
|
||||
const LoadingDots = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<span className={`${dots} ${className}`} />
|
||||
<span className={`${dots} animation-delay-[200ms] ${className}`} />
|
||||
<span className={`${dots} animation-delay-[400ms] ${className}`} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingDots;
|
||||
@ -1,33 +0,0 @@
|
||||
|
||||
import React from "react";
|
||||
const SkeletonCards = () => {
|
||||
return (
|
||||
<section>
|
||||
<div className="container">
|
||||
<div className="row gy-4">
|
||||
<div className="col-12 mx-auto">
|
||||
<div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array(9)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="h-[200px] md:h-[269px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="mt-4 w-24 h-3 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div className="mt-2 w-16 h-2 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonCards;
|
||||
@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
const SkeletonCategory = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-x-6">
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="h-[150px] md:h-[250px] lg:h-[306px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonCategory;
|
||||
@ -1,25 +0,0 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
const SkeletonDescription = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b-2 border-border dark:border-light flex gap-x-6">
|
||||
<div
|
||||
className={`w-[13%] border-t-2 border-l-2 border-r-2 border-b-0 border-border dark:border-light animate-pulse bg-neutral-200 dark:bg-neutral-700 px-6 rounded-tl-md rounded-tr-md h-12`}
|
||||
/>
|
||||
<div
|
||||
className={`w-[13%] border-t-2 border-l-2 border-r-2 border-border dark:border-light border-b-0 animate-pulse bg-neutral-200 dark:bg-neutral-700 px-6 rounded-tl-md rounded-tr-md h-12`}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-l-2 border-r-2 border-b-2 border border-border dark:border-light rounded-bl-md rounded-br-md p-6">
|
||||
<div className="h-10 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div>
|
||||
<div className="h-10 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonDescription;
|
||||
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const SkeletonFeaturedProducts = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array(8)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="h-[150px] md:h-[269px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="mt-4 w-24 h-3 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div className="mt-2 w-16 h-2 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonFeaturedProducts;
|
||||
@ -1,65 +0,0 @@
|
||||
import React from "react";
|
||||
const SkeletonProductGallery = () => {
|
||||
return (
|
||||
<>
|
||||
<section className="md:section-sm">
|
||||
<div className="container">
|
||||
<div className="row justify-center">
|
||||
{/* right side contents */}
|
||||
<div className="col-10 md:col-8 lg:col-6">
|
||||
<div className="h-[323px] md:h-[623px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700 mb-4"></div>
|
||||
<div className="grid grid-cols-4 gap-x-4">
|
||||
{Array(4)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="h-[80px] md:h-[146px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* left side contents */}
|
||||
<div className="col-10 md:col-8 lg:col-5 md:ml-7 py-6 lg:py-0">
|
||||
{Array(8)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700"
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pt-14 xl:pt-28">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array(9)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="h-[150px] md:h-[269px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="mt-4 w-24 h-3 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div className="mt-2 w-16 h-2 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonProductGallery;
|
||||
@ -1,26 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const SkeletonProductThumb = () => {
|
||||
return (
|
||||
<div className="row justify-center">
|
||||
<div>
|
||||
<div className="h-[323px] md:h-[623px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700 mb-4"></div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-x-4">
|
||||
{Array(4)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="h-[80px] md:h-[146px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonProductThumb;
|
||||
@ -1,42 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const SkeletonProducts = () => {
|
||||
return (
|
||||
<section className="pt-14 xl:pt-28">
|
||||
<div className="container">
|
||||
<div className="row gy-4">
|
||||
<div className="col-12 lg:col-3">
|
||||
<div className="hidden lg:block h-8 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="hidden lg:block h-full rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-9">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-8 w-2/12 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="h-8 w-3/12 mb-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array(9)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="h-[150px] md:h-[269px] rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="mt-4 w-24 h-3 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
<div className="mt-2 w-16 h-2 rounded-full animate-pulse bg-neutral-200 dark:bg-neutral-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonProducts;
|
||||