diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/header.js | 68 | ||||
| -rw-r--r-- | src/components/productCard.js | 35 | ||||
| -rw-r--r-- | src/helpers/apiOdoo.js | 21 | ||||
| -rw-r--r-- | src/helpers/currencyFormat.js | 8 | ||||
| -rw-r--r-- | src/helpers/slug.js | 13 | ||||
| -rw-r--r-- | src/icons/menu.svg | 5 | ||||
| -rw-r--r-- | src/icons/search.svg | 4 | ||||
| -rw-r--r-- | src/icons/shopping-cart.svg | 12 | ||||
| -rw-r--r-- | src/pages/_app.js | 7 | ||||
| -rw-r--r-- | src/pages/index.js | 12 | ||||
| -rw-r--r-- | src/pages/shop/product/[slug].js | 172 | ||||
| -rw-r--r-- | src/styles/globals.css | 220 |
12 files changed, 577 insertions, 0 deletions
diff --git a/src/components/header.js b/src/components/header.js new file mode 100644 index 00000000..baebbd3a --- /dev/null +++ b/src/components/header.js @@ -0,0 +1,68 @@ +import Image from "next/image"; +import Link from "next/link"; +import ShoppingCartIcon from "../icons/shopping-cart.svg"; +import SearchIcon from "../icons/search.svg"; +import MenuIcon from "../icons/menu.svg"; +import { useState } from "react"; + + +export default function Header() { + const [isMenuActive, setIsMenuActive] = useState(false); + + const openMenu = () => setIsMenuActive(true); + const closeMenu = () => setIsMenuActive(false); + + return ( + <> + <div className={isMenuActive ? 'menu-wrapper active' : 'menu-wrapper'}> + <div className="flex gap-x-2 items-center"> + <Link href="/login" className="w-full py-2 btn-light">Masuk</Link> + <Link href="/register" className="w-full py-2 btn-primary">Daftar</Link> + </div> + <div className="flex flex-col gap-y-4 mt-5"> + <Link className="flex w-full font-normal text-gray-800" href="/shop/brands"> + <span>Brand</span> + <div className="ml-auto"> + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="stroke-gray-700 feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg> + </div> + </Link> + <Link className="flex w-full font-normal text-gray-800" href="/blog"> + <span>Blog</span> + <div className="ml-auto"> + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="stroke-gray-700 feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg> + </div> + </Link> + <button className="flex w-full font-normal text-gray-800" id="open_category_parent_menu"> + <span>Kategori</span> + <div className="ml-auto"> + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="stroke-gray-700 feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg> + </div> + </button> + </div> + </div> + <div className={isMenuActive ? 'menu-overlay block opacity-100' : 'menu-overlay hidden opacity-0'} onClick={closeMenu}></div> + + <div className="sticky-header"> + <div className="flex justify-between items-center"> + <Link href="/"> + <Image src="/images/logo.png" alt="Logo Indoteknik" width={120} height={40} /> + </Link> + <div className="flex gap-4"> + <Link href="/shop/cart"> + <ShoppingCartIcon className="w-6" /> + </Link> + <button onClick={openMenu}> + <MenuIcon className="w-6" /> + </button> + </div> + </div> + <form action="" method="GET" className="flex mt-2"> + <input type="text" name="product_name" className="form-input rounded-r-none border-r-0 focus:outline-none" placeholder="Ketikan nama, merek, part number"/> + <button type="submit" aria-label="search" className="btn-light bg-transparent px-2 py-1 rounded-l-none border-l-0"> + <SearchIcon /> + </button> + </form> + </div> + </> + ) +}
\ No newline at end of file diff --git a/src/components/productCard.js b/src/components/productCard.js new file mode 100644 index 00000000..dc292316 --- /dev/null +++ b/src/components/productCard.js @@ -0,0 +1,35 @@ +import Link from "next/link"; +import currencyFormat from "../helpers/currencyFormat"; +import { createSlug } from "../helpers/slug"; + +export default function productCard({ data }) { + let product = data; + return ( + <div className="product-card"> + <Link href={'/shop/product/' + createSlug(product.name, product.id)} className="block"> + <img src={product.image} alt={product.name} className="product-card__image" loading="lazy" /> + </Link> + <div className="product-card__description"> + <div> + {typeof product.manufacture.name !== undefined ? ( + <a href={'/shop/brands/' + createSlug(product.manufacture.name, product.manufacture.id)} className="product-card__brand">{product.manufacture.name}</a> + ) : ( + <span className="product-card__brand">-</span> + )} + <a href={'/shop/product/' + createSlug(product.name, product.id)} className="product-card__title wrap-line-ellipsis-3"> + {product.name} + </a> + </div> + <div> + {product.lowest_price.discount_percentage > 0 ? ( + <div className="flex gap-x-1 items-center mt-2"> + <span className="badge-yellow">{product.lowest_price.discount_percentage}%</span> + <p className="text-xs text-gray-800 line-through">{currencyFormat(product.lowest_price.price)}</p> + </div> + ) : ''} + <p className="text-sm text-gray-900 font-semibold">{product.lowest_price.price_discount > 0 ? currencyFormat(product.lowest_price.price_discount) : 'Tanya harga'}</p> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/src/helpers/apiOdoo.js b/src/helpers/apiOdoo.js new file mode 100644 index 00000000..632edf61 --- /dev/null +++ b/src/helpers/apiOdoo.js @@ -0,0 +1,21 @@ +const axios = require('axios'); + +const getOdoo = async (url) => { + try { + let res = await axios(process.env.DB_HOST + url, { + headers: { + db: process.env.DB_NAME, + username: process.env.DB_USER, + password: process.env.DB_PASS, + } + }); + + return res.data.result || []; + } catch (error) { + console.log(error); + } +} + +export { + getOdoo, +};
\ No newline at end of file diff --git a/src/helpers/currencyFormat.js b/src/helpers/currencyFormat.js new file mode 100644 index 00000000..dadeaec6 --- /dev/null +++ b/src/helpers/currencyFormat.js @@ -0,0 +1,8 @@ +export default function currencyFormat(value) { + const currency = new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0 + }); + return currency.format(value); +}
\ No newline at end of file diff --git a/src/helpers/slug.js b/src/helpers/slug.js new file mode 100644 index 00000000..5a8db08f --- /dev/null +++ b/src/helpers/slug.js @@ -0,0 +1,13 @@ +const createSlug = (name, id) => { + return name.trim().replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-').toLowerCase() + '-' + id; +} + +const getId = (slug) => { + let id = slug.split('-'); + return id[id.length-1]; +} + +export { + createSlug, + getId +};
\ No newline at end of file diff --git a/src/icons/menu.svg b/src/icons/menu.svg new file mode 100644 index 00000000..f4ff45ad --- /dev/null +++ b/src/icons/menu.svg @@ -0,0 +1,5 @@ +<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.04395 10H18.0439" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M3.04395 5H18.0439" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M3.04395 15H18.0439" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 00000000..6de1cdfa --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,4 @@ +<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M9.71061 15.8333C13.3925 15.8333 16.3773 12.8486 16.3773 9.16667C16.3773 5.48477 13.3925 2.5 9.71061 2.5C6.02871 2.5 3.04395 5.48477 3.04395 9.16667C3.04395 12.8486 6.02871 15.8333 9.71061 15.8333Z" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M18.0439 17.5L14.4189 13.875" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/src/icons/shopping-cart.svg b/src/icons/shopping-cart.svg new file mode 100644 index 00000000..5c899876 --- /dev/null +++ b/src/icons/shopping-cart.svg @@ -0,0 +1,12 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_10_13)"> +<path d="M7.50008 18.3334C7.96032 18.3334 8.33341 17.9603 8.33341 17.5C8.33341 17.0398 7.96032 16.6667 7.50008 16.6667C7.03984 16.6667 6.66675 17.0398 6.66675 17.5C6.66675 17.9603 7.03984 18.3334 7.50008 18.3334Z" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M16.6666 18.3334C17.1268 18.3334 17.4999 17.9603 17.4999 17.5C17.4999 17.0398 17.1268 16.6667 16.6666 16.6667C16.2063 16.6667 15.8333 17.0398 15.8333 17.5C15.8333 17.9603 16.2063 18.3334 16.6666 18.3334Z" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M0.833252 0.833313H4.16659L6.39992 11.9916C6.47612 12.3753 6.68484 12.7199 6.98954 12.9652C7.29424 13.2105 7.6755 13.3408 8.06659 13.3333H16.1666C16.5577 13.3408 16.9389 13.2105 17.2436 12.9652C17.5483 12.7199 17.757 12.3753 17.8333 11.9916L19.1666 4.99998H4.99992" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<clipPath id="clip0_10_13"> +<rect width="20" height="20" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/pages/_app.js b/src/pages/_app.js new file mode 100644 index 00000000..1e1cec92 --- /dev/null +++ b/src/pages/_app.js @@ -0,0 +1,7 @@ +import '../styles/globals.css' + +function MyApp({ Component, pageProps }) { + return <Component {...pageProps} /> +} + +export default MyApp diff --git a/src/pages/index.js b/src/pages/index.js new file mode 100644 index 00000000..57f96ec9 --- /dev/null +++ b/src/pages/index.js @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; +import Header from "../components/header"; + +export default function Home() { + const [product, setProduct] = useState({}); + + return ( + <> + <Header /> + </> + ) +} diff --git a/src/pages/shop/product/[slug].js b/src/pages/shop/product/[slug].js new file mode 100644 index 00000000..519f8160 --- /dev/null +++ b/src/pages/shop/product/[slug].js @@ -0,0 +1,172 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import Header from "../../../components/header"; +import ProductCard from "../../../components/productCard"; +import { getOdoo } from "../../../helpers/apiOdoo"; +import { createSlug, getId } from "../../../helpers/slug"; +import currencyFormat from "../../../helpers/currencyFormat"; +import Head from "next/head"; +import { Swiper, SwiperSlide } from "swiper/react"; + +import 'swiper/css'; + + +export async function getServerSideProps(context) { + const { slug } = context.query; + let product = await getOdoo('/api/v1/product/' + getId(slug)); + product = product[0]; + + const similarProducts = await getOdoo(`/api/v1/product/${getId(slug)}/similar?limit=20`); + + return {props: {product, similarProducts}}; +} + +export default function ProductDetail({product, similarProducts}) { + const router = useRouter(); + const { slug } = router.query; + const [selectedVariant, setSelectedVariant] = useState(""); + const [quantity, setQuantity] = useState("1"); + const [activeVariant, setActiveVariant] = useState({ + id: product.id, + code: product.code, + price: product.lowest_price, + stock: product.stock_total, + weight: product.weight, + attributes: '', + }); + + useEffect(() => { + if (product.variants.length == 1) { + setSelectedVariant(product.variants[0].id); + } + }, [product.variants]); + + useEffect(() => { + if (selectedVariant != '') { + let newActiveVariant = product.variants.filter((variant) => { + return variant.id == selectedVariant; + }); + + if (newActiveVariant.length == 1) { + newActiveVariant = newActiveVariant[0]; + setActiveVariant({ + id: newActiveVariant.id, + code: newActiveVariant.code, + price: newActiveVariant.price, + stock: newActiveVariant.stock, + weight: newActiveVariant.weight, + attributes: newActiveVariant.attributes.join(', '), + }); + } + } + }, [selectedVariant]) + + let onchangeVariant = (e) => { + setSelectedVariant(e.target.value); + setQuantity("1"); + } + + let addToCart = () => { + return true; + } + + return ( + <> + <Head> + <title>{product.name + '- Indoteknik'}</title> + </Head> + <Header /> + <div className="px-4 pt-5 pb-10"> + <img src={product.image} alt={product.name} className="border border-gray-300 rounded-md mb-4 w-full h-[300px] object-contain object-center" /> + <Link href={'/shop/brands/' + createSlug(product.manufacture.name, product.manufacture.id)}> + {product.manufacture.name || '-'} + </Link> + <h1 className="mb-3">{product.name}{activeVariant.attributes != '' ? ' - ' + activeVariant.attributes : ''}</h1> + + {product.variant_total > 1 && selectedVariant == "" ? ( + <p className="text-xs text-gray-800 mb-1">Harga mulai dari:</p> + ) : ''} + + {product.lowest_price.discount_percentage > 0 ? ( + <div className="flex gap-x-1 items-center"> + <span className="badge-yellow">{activeVariant.price.discount_percentage}%</span> + <p className="text-xs text-gray-800 line-through">{currencyFormat(activeVariant.price.price)}</p> + </div> + ) : ''} + + {product.lowest_price.price > 0 ? ( + <p className="text-lg text-gray-900 font-semibold">{currencyFormat(activeVariant.price.price_discount)}</p> + ) : ( + <p className="text-gray-800">Dapatkan harga terbaik, <a href="">hubungi kami.</a></p> + )} + + <div className="flex gap-x-2 mt-5"> + <div className="w-9/12"> + <label className="form-label">Pilih: <span className="text-gray-800">{product.variant_total} Varian</span></label> + <select name="variant" className="form-input" value={selectedVariant} onChange={onchangeVariant} > + <option value="" disabled={selectedVariant != "" ? true : false}>Pilih Varian...</option> + {product.variants.length > 1 ? ( + product.variants.map((variant) => { + return ( + <option key={variant.id} value={variant.id}>{variant.attributes.join(', ')}</option> + ); + }) + ) : ( + <option key={product.variants[0].id} value={product.variants[0].id}>{product.variants[0].name}</option> + )} + </select> + </div> + <div className="w-3/12"> + <label htmlFor="quantity" className="form-label">Jumlah</label> + <input type="number" name="quantity" id="quantity" className="form-input text-center is-invalid" value={quantity} onChange={(e) => setQuantity(e.target.value)} /> + </div> + </div> + + <div className="flex gap-x-2 mt-2"> + <button className="btn-light w-full" >+ Quotation</button> + <button className="btn-primary w-full" onClick={addToCart} disabled={(product.lowest_price.price == 0 ? true : false)}>+ Keranjang</button> + </div> + + <div className="mt-10"> + <h2 className="h1 mb-2">Detail Produk</h2> + <div className="flex py-2 justify-between items-center gap-x-1 border-b border-gray-300"> + <h3 className="text-gray-900">Jumlah Varian</h3> + <p className="text-gray-800">{product.variant_total} Varian</p> + </div> + <div className="flex py-2 justify-between items-center gap-x-1 border-b border-gray-300"> + <h3 className="text-gray-900">Nomor SKU</h3> + <p className="text-gray-800" id="sku_number">SKU-{activeVariant.id}</p> + </div> + <div className="flex py-2 justify-between items-center gap-x-1 border-b border-gray-300"> + <h3 className="text-gray-900">Part Number</h3> + <p className="text-gray-800" id="part_number">{activeVariant.code}</p> + </div> + <div className="flex py-2 justify-between items-center gap-x-1 border-b border-gray-300"> + <h3 className="text-gray-900">Stok</h3> + <p className="text-gray-800" id="stock"> + {activeVariant.stock > 0 ? (activeVariant.stock > 5 ? 'Lebih dari 5' : 'Kurang dari 5') : '0'} + </p> + </div> + <div className="flex py-2 justify-between items-center gap-x-1 border-b border-gray-300"> + <h3 className="text-gray-900">Berat Barang</h3> + <p className="text-gray-800" id="weight">{activeVariant.weight > 0 ? activeVariant.weight : '1'} KG</p> + </div> + </div> + + <div className="mt-10"> + <h2 className="h1 mb-4">Deskripsi Produk</h2> + <div className="text-gray-800 leading-7" dangerouslySetInnerHTML={{__html: (product.description.trim() != '' ? product.description.replaceAll(/<*b>/g, '') : 'Belum ada deskripsi produk.')}}></div> + </div> + + <div className="mt-10"> + <h2 className="h1 mb-4">Produk Lainnya</h2> + <Swiper freeMode={true} slidesPerView={2.15} spaceBetween={8} loop={true}> + {similarProducts.products.map((product, index) => (<SwiperSlide key={index}><ProductCard data={product} /></SwiperSlide>))} + </Swiper> + </div> + + </div> + </> + ); +}
\ No newline at end of file diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 00000000..763a6d39 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,220 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body { + @apply max-w-full; + @apply text-base; + @apply text-gray-900; +} + +@layer base { + h1, .h1 { + @apply text-lg; + @apply font-semibold; + @apply text-gray-900; + } + + a { + @apply font-medium; + @apply text-yellow-900; + } +} + +@layer components { + .badge-red { + @apply text-xs; + @apply font-medium; + @apply px-1; + @apply py-0.5; + @apply rounded; + @apply bg-red-300; + @apply text-red-900; + } + + .badge-yellow { + @apply text-xs; + @apply font-medium; + @apply px-1; + @apply py-0.5; + @apply rounded; + @apply bg-yellow-300; + @apply text-yellow-900; + } + + .form-label { + @apply text-sm; + @apply mb-1; + @apply block; + } + + .form-input { + @apply p-3; + @apply rounded; + @apply border; + @apply border-gray-300; + @apply bg-transparent; + @apply w-full; + @apply leading-none; + } + + .btn-primary { + @apply p-3; + @apply bg-yellow-900; + @apply border-yellow-900; + @apply rounded; + @apply border; + @apply text-white; + @apply text-center; + @apply disabled:bg-yellow-700; + @apply disabled:border-yellow-700; + } + + .btn-light { + @apply p-3; + @apply bg-gray-100; + @apply border-gray-300; + @apply rounded; + @apply border; + @apply text-gray-900; + @apply text-center; + } + + .product-card { + @apply w-full; + @apply h-full; + @apply border; + @apply border-gray-300; + @apply rounded; + @apply relative; + @apply flex; + @apply flex-col; + } + + .product-card__image { + @apply w-full; + @apply h-[160px]; + @apply object-contain; + @apply object-center; + @apply border-b; + @apply border-gray-300; + } + + .product-card__description { + @apply p-2; + @apply pb-3; + @apply flex; + @apply flex-col; + @apply flex-1; + @apply justify-between; + } + + .product-card__title { + @apply text-sm; + @apply text-gray-900; + } + + .product-card__brand { + @apply text-sm; + } +} + +@layer utilities { + .wrap-line-ellipsis-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .wrap-line-ellipsis-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.menu-wrapper { + @apply fixed; + @apply top-0; + @apply left-0; + @apply bg-white; + @apply w-[85%]; + @apply h-full; + @apply z-[60]; + @apply py-4; + @apply px-4; + @apply overflow-y-auto; + @apply translate-x-[-100%]; + @apply ease-linear; + @apply duration-150; +} + +.menu-wrapper.active{ + @apply translate-x-0; +} + +.menu-overlay { + @apply fixed; + @apply top-0; + @apply left-0; + @apply w-full; + @apply h-full; + @apply z-[55]; + @apply bg-gray-900/60; +} + +.sticky-header { + @apply px-4; + @apply py-3; + @apply bg-white; + @apply border-b; + @apply sticky; + @apply top-0; + @apply shadow; + @apply z-50; +} + +.content-container { + @apply max-w-full; + @apply overflow-x-hidden; +} + +#indoteknik_toast { + @apply fixed; + @apply bottom-4; + @apply translate-y-[200%]; + @apply left-[50%]; + @apply translate-x-[-50%]; + @apply z-[100]; + @apply flex; + @apply items-center; + @apply p-4; + @apply mb-4; + @apply w-[90%]; + @apply text-gray-500; + @apply bg-white; + @apply border; + @apply border-gray-300; + @apply rounded-lg; + @apply shadow; + @apply ease-linear; + @apply duration-300; +} + +#indoteknik_toast.active { + @apply translate-y-0; +} + +.category-menu { + @apply hidden; +} + +.swiper-slide { + @apply !h-auto; +}
\ No newline at end of file |
