@pradip1995/theme-impulse 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +31 -0
  2. package/assets/hero-desktop.svg +10 -0
  3. package/assets/hero-mobile.svg +9 -0
  4. package/assets/logo.svg +3 -0
  5. package/package.json +60 -0
  6. package/src/blocks/home/Features/index.tsx +37 -0
  7. package/src/blocks/home/Hero/index.tsx +102 -0
  8. package/src/blocks/home/LovedByMoms/index.tsx +59 -0
  9. package/src/blocks/home/NewArrivals/index.tsx +57 -0
  10. package/src/blocks/home/ShopByAge/index.tsx +82 -0
  11. package/src/blocks/home/ShopByCategory/index.tsx +92 -0
  12. package/src/blocks/home/Testimonials/index.tsx +130 -0
  13. package/src/blocks/home/WhyChooseUs/index.tsx +46 -0
  14. package/src/layouts/MainLayoutShell.tsx +14 -0
  15. package/src/primitives/Button.tsx +31 -0
  16. package/src/primitives/Card.tsx +32 -0
  17. package/src/primitives/index.ts +2 -0
  18. package/src/slots/account/ForgotPassword/index.tsx +1 -0
  19. package/src/slots/account/Login/index.tsx +1 -0
  20. package/src/slots/account/LoginTemplate/index.tsx +44 -0
  21. package/src/slots/account/Register/index.tsx +1 -0
  22. package/src/slots/cart/CartItem/index.tsx +11 -0
  23. package/src/slots/cart/CartSummary/index.tsx +13 -0
  24. package/src/slots/checkout/CheckoutForm/index.tsx +1 -0
  25. package/src/slots/checkout/CheckoutSummary/index.tsx +1 -0
  26. package/src/slots/layout/Footer/index.tsx +103 -0
  27. package/src/slots/layout/Nav/index.tsx +97 -0
  28. package/src/slots/layout/PromoBar/index.tsx +19 -0
  29. package/src/slots/layout/PromoBar/promo-bar-content.tsx +174 -0
  30. package/src/slots/order/OrderDetails/index.tsx +12 -0
  31. package/src/slots/product/ProductActions/ProductCTASection.tsx +191 -0
  32. package/src/slots/product/ProductActions/ProductDetailsSection.tsx +137 -0
  33. package/src/slots/product/ProductActions/ProductFeaturePanel.tsx +245 -0
  34. package/src/slots/product/ProductActions/ProductHighlightsSection.tsx +99 -0
  35. package/src/slots/product/ProductActions/ProductOptionsSection.tsx +234 -0
  36. package/src/slots/product/ProductActions/ProductPriceSection.tsx +53 -0
  37. package/src/slots/product/ProductActions/ProductTrustSection.tsx +84 -0
  38. package/src/slots/product/ProductActions/index.tsx +161 -0
  39. package/src/slots/product/ProductCard/index.tsx +132 -0
  40. package/src/slots/product/ProductInfo/index.tsx +40 -0
  41. package/src/templates/StorePage/index.tsx +154 -0
  42. package/src/tokens/colors.js +16 -0
  43. package/src/tokens/colors.ts +21 -0
  44. package/src/tokens/fonts.ts +13 -0
  45. package/src/tokens/index.ts +3 -0
  46. package/src/tokens/spacing.ts +9 -0
  47. package/src/tokens/theme.css +89 -0
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @pradip1995/theme-impulse
2
+
3
+ Shopify [Impulse](https://themes.shopify.com/themes/impulse/presets/impulse)-inspired storefront theme for Medusa.
4
+
5
+ ## Design highlights
6
+
7
+ - **Karla + Playfair Display** typography with wide uppercase nav tracking
8
+ - **Full-bleed hero** with bottom overlay and bordered CTA
9
+ - **Promo tiles** — 3-up collection blocks below the hero
10
+ - **Category image grid** with hover zoom
11
+ - **Product cards** with image rollover and quick-add
12
+ - **Trust badge strip** and editorial testimonial carousel
13
+ - **Sticky header**, promo bar, dark footer
14
+
15
+ ## Use in a storefront
16
+
17
+ ```bash
18
+ ./scripts/create-storefront.sh my-shop --theme impulse
19
+ ```
20
+
21
+ Or as an npm package (CSS-only overrides):
22
+
23
+ ```ts
24
+ import "@pradip1995/theme-impulse/tokens/theme.css"
25
+ ```
26
+
27
+ ## Fork for full customization
28
+
29
+ ```bash
30
+ ./scripts/create-theme.sh my-brand --from impulse
31
+ ```
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMid slice">
2
+ <defs>
3
+ <linearGradient id="hero" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#e8e4df"/>
5
+ <stop offset="50%" stop-color="#d4cfc8"/>
6
+ <stop offset="100%" stop-color="#b8b0a6"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect width="1440" height="600" fill="url(#hero)"/>
10
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 960" preserveAspectRatio="xMidYMid slice">
2
+ <defs>
3
+ <linearGradient id="hero-m" x1="0%" y1="0%" x2="0%" y2="100%">
4
+ <stop offset="0%" stop-color="#e8e4df"/>
5
+ <stop offset="100%" stop-color="#b8b0a6"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="750" height="960" fill="url(#hero-m)"/>
9
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 48" fill="none">
2
+ <text x="0" y="34" font-family="Georgia, serif" font-size="28" fill="#1a1a1a">Your Store</text>
3
+ </svg>
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@pradip1995/theme-impulse",
3
+ "version": "1.1.4",
4
+ "description": "Shopify Impulse-inspired Medusa storefront theme — promo tiles, editorial typography, mobile-first product grid",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "registry": "https://registry.npmjs.org/"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/SmartByteLabs/medusa-storefront-kit.git",
13
+ "directory": "packages/theme-impulse"
14
+ },
15
+ "sideEffects": [
16
+ "**/*.css"
17
+ ],
18
+ "files": [
19
+ "src",
20
+ "assets"
21
+ ],
22
+ "exports": {
23
+ "./tokens/theme.css": "./src/tokens/theme.css",
24
+ "./tokens": "./src/tokens/index.ts",
25
+ "./tokens/*": "./src/tokens/*",
26
+ "./layouts/*": "./src/layouts/*",
27
+ "./primitives": "./src/primitives/index.ts",
28
+ "./primitives/*": "./src/primitives/*",
29
+ "./blocks/*": "./src/blocks/*/index.tsx",
30
+ "./slots/*": "./src/slots/*/index.tsx",
31
+ "./*": "./src/*"
32
+ },
33
+ "peerDependencies": {
34
+ "@pradip1995/commerce-auth": "^1.1.4",
35
+ "@pradip1995/commerce-core": "^1.1.4",
36
+ "@medusajs/types": ">=2",
37
+ "@medusajs/ui": ">=4",
38
+ "color": ">=5",
39
+ "next": ">=15",
40
+ "react": ">=19",
41
+ "react-dom": ">=19"
42
+ },
43
+ "devDependencies": {
44
+ "@pradip1995/commerce-auth": "^1.1.4",
45
+ "@pradip1995/commerce-core": "^1.1.4",
46
+ "@medusajs/types": "latest",
47
+ "@medusajs/ui": "latest",
48
+ "@types/color": "^4.2.0",
49
+ "@types/react": "^19",
50
+ "color": "^5.0.3",
51
+ "next": "15.3.8",
52
+ "react": "19.0.3",
53
+ "react-dom": "19.0.3",
54
+ "typescript": "^5.7.2"
55
+ },
56
+ "scripts": {
57
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
58
+ "lint": "tsc --noEmit -p tsconfig.typecheck.json"
59
+ }
60
+ }
@@ -0,0 +1,37 @@
1
+ const features = [
2
+ { name: "Free shipping" },
3
+ { name: "Easy returns" },
4
+ { name: "Secure checkout" },
5
+ { name: "Customer support" },
6
+ ]
7
+
8
+ /** Impulse-style trust badge strip. */
9
+ const Features = () => {
10
+ return (
11
+ <section className="w-full border-y border-[var(--color-border)] bg-page-bg">
12
+ <div
13
+ className="mx-auto px-4 sm:px-6 py-5 md:py-6"
14
+ style={{ maxWidth: "var(--container-max)" }}
15
+ >
16
+ <ul className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8">
17
+ {features.map((feature, index) => (
18
+ <li
19
+ key={feature.name}
20
+ className={`flex items-center justify-center text-center ${
21
+ index < features.length - 1
22
+ ? "md:border-r md:border-[var(--color-border)]"
23
+ : ""
24
+ }`}
25
+ >
26
+ <span className="text-[10px] sm:text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-heading">
27
+ {feature.name}
28
+ </span>
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </div>
33
+ </section>
34
+ )
35
+ }
36
+
37
+ export default Features
@@ -0,0 +1,102 @@
1
+ import Image from "next/image"
2
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
3
+ import type { HeroBlockData } from "@core/types/home"
4
+
5
+ const Hero = ({ homeBanner, appBanner }: HeroBlockData) => {
6
+ const desktopImage = homeBanner.image!
7
+ const mobileImage = appBanner.image || desktopImage
8
+
9
+ return (
10
+ <section className="w-full">
11
+ {/* Desktop — Impulse full-bleed hero with overlay */}
12
+ <div className="hidden min-[551px]:block relative w-full min-h-[420px] md:min-h-[520px] lg:min-h-[600px]">
13
+ <Image
14
+ src={desktopImage}
15
+ alt={homeBanner.subtitle || homeBanner.title || "Hero banner"}
16
+ fill
17
+ priority
18
+ sizes="100vw"
19
+ className="object-cover"
20
+ />
21
+ <div
22
+ className="absolute inset-0"
23
+ style={{ background: "var(--gradient-hero)" }}
24
+ />
25
+ <div className="absolute inset-0 flex items-end">
26
+ <div
27
+ className="w-full mx-auto px-8 md:px-12 lg:px-16 pb-12 md:pb-16 lg:pb-20"
28
+ style={{ maxWidth: "var(--container-max)" }}
29
+ >
30
+ <div className="max-w-xl text-[var(--color-text-inverse)]">
31
+ {homeBanner.title && (
32
+ <p className="text-xs md:text-sm font-semibold uppercase tracking-[var(--letter-spacing-nav)] mb-3 opacity-90">
33
+ {homeBanner.title}
34
+ </p>
35
+ )}
36
+ {homeBanner.subtitle && (
37
+ <h1 className="font-display text-3xl md:text-5xl lg:text-6xl font-normal leading-heading mb-4">
38
+ {homeBanner.subtitle}
39
+ </h1>
40
+ )}
41
+ {homeBanner.description && (
42
+ <p className="text-sm md:text-base text-white/85 mb-8 max-w-md leading-relaxed">
43
+ {homeBanner.description}
44
+ </p>
45
+ )}
46
+ {homeBanner.buttonName && (
47
+ <LocalizedClientLink href={homeBanner.buttonLink || "/store"}>
48
+ <span
49
+ className="inline-block border-2 border-white text-white px-8 py-3 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] hover:bg-white hover:text-brand-accent transition-colors duration-300"
50
+ data-ga-event="hero_banner_click"
51
+ data-ga-label={homeBanner.buttonName}
52
+ >
53
+ {homeBanner.buttonName}
54
+ </span>
55
+ </LocalizedClientLink>
56
+ )}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ {/* Mobile */}
63
+ <div className="block min-[551px]:hidden relative w-full min-h-[480px]">
64
+ <Image
65
+ src={mobileImage}
66
+ alt={appBanner.subtitle || appBanner.title || "Hero banner"}
67
+ fill
68
+ priority
69
+ sizes="100vw"
70
+ className="object-cover"
71
+ />
72
+ <div
73
+ className="absolute inset-0"
74
+ style={{ background: "var(--gradient-hero)" }}
75
+ />
76
+ <div className="absolute inset-0 flex items-end px-6 pb-10">
77
+ <div className="text-[var(--color-text-inverse)]">
78
+ {appBanner.title && (
79
+ <p className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] mb-2 opacity-90">
80
+ {appBanner.title}
81
+ </p>
82
+ )}
83
+ {appBanner.subtitle && (
84
+ <h1 className="font-display text-2xl font-normal leading-heading mb-3">
85
+ {appBanner.subtitle}
86
+ </h1>
87
+ )}
88
+ {appBanner.buttonName && (
89
+ <LocalizedClientLink href={appBanner.buttonLink || "/store"}>
90
+ <span className="inline-block border-2 border-white text-white px-6 py-2.5 text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)]">
91
+ {appBanner.buttonName}
92
+ </span>
93
+ </LocalizedClientLink>
94
+ )}
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </section>
99
+ )
100
+ }
101
+
102
+ export default Hero
@@ -0,0 +1,59 @@
1
+ "use client"
2
+
3
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
4
+ import { HttpTypes } from "@medusajs/types"
5
+ import ProductCard from "@theme/slots/product/ProductCard"
6
+
7
+ type LovedByMomsProps = {
8
+ products: HttpTypes.StoreProduct[]
9
+ region: HttpTypes.StoreRegion
10
+ ratings?: { product_id?: string }[]
11
+ }
12
+
13
+ /** Impulse-style featured / best sellers product row. */
14
+ const LovedByMoms = ({ products, region, ratings }: LovedByMomsProps) => {
15
+ const safeProducts = (products || []).slice(0, 4)
16
+
17
+ if (safeProducts.length === 0) {
18
+ return null
19
+ }
20
+
21
+ return (
22
+ <section className="w-full py-10 md:py-16 bg-surface-muted">
23
+ <div
24
+ className="mx-auto px-4 sm:px-6 lg:px-8"
25
+ style={{ maxWidth: "var(--container-max)" }}
26
+ >
27
+ <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8 md:mb-10">
28
+ <div>
29
+ <p className="text-[10px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-[var(--color-text-muted)] mb-2">
30
+ Customer favorites
31
+ </p>
32
+ <h2 className="font-display text-2xl md:text-3xl text-heading">
33
+ Best sellers
34
+ </h2>
35
+ </div>
36
+ <LocalizedClientLink
37
+ href="/store?sortBy=bestsellers"
38
+ className="text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-heading border-b border-heading pb-0.5 hover:opacity-70 transition-opacity self-start sm:self-auto"
39
+ >
40
+ View all
41
+ </LocalizedClientLink>
42
+ </div>
43
+
44
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
45
+ {safeProducts.map((product) => (
46
+ <ProductCard
47
+ key={product.id}
48
+ product={product}
49
+ region={region}
50
+ rating={ratings?.find((r) => r.product_id === product.id)}
51
+ />
52
+ ))}
53
+ </div>
54
+ </div>
55
+ </section>
56
+ )
57
+ }
58
+
59
+ export default LovedByMoms
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
5
+ import ProductCard from "@theme/slots/product/ProductCard"
6
+
7
+ type NewArrivalsProps = {
8
+ products: HttpTypes.StoreProduct[]
9
+ region: HttpTypes.StoreRegion
10
+ ratings?: { product_id?: string }[]
11
+ }
12
+
13
+ const NewArrivals = ({ products, region, ratings }: NewArrivalsProps) => {
14
+ const safeProducts = (products || []).slice(0, 8)
15
+
16
+ return (
17
+ <section className="w-full py-10 md:py-16 bg-page-bg">
18
+ <div
19
+ className="mx-auto px-4 sm:px-6 lg:px-8"
20
+ style={{ maxWidth: "var(--container-max)" }}
21
+ >
22
+ <div className="flex items-end justify-between gap-4 mb-8 md:mb-10">
23
+ <h2 className="font-display text-2xl md:text-3xl text-heading">
24
+ New arrivals
25
+ </h2>
26
+ <LocalizedClientLink
27
+ href="/store"
28
+ className="text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-heading border-b border-heading pb-0.5 hover:opacity-70 transition-opacity shrink-0"
29
+ >
30
+ View all
31
+ </LocalizedClientLink>
32
+ </div>
33
+
34
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6 lg:gap-8">
35
+ {safeProducts.length > 0 ? (
36
+ safeProducts.map((product) => (
37
+ <ProductCard
38
+ key={product.id}
39
+ product={product}
40
+ region={region}
41
+ rating={ratings?.find((r) => r.product_id === product.id)}
42
+ />
43
+ ))
44
+ ) : (
45
+ <div className="col-span-full text-center py-12">
46
+ <p className="text-[var(--color-text-muted)]">
47
+ No new arrivals at the moment. Check back soon!
48
+ </p>
49
+ </div>
50
+ )}
51
+ </div>
52
+ </div>
53
+ </section>
54
+ )
55
+ }
56
+
57
+ export default NewArrivals
@@ -0,0 +1,82 @@
1
+ import Image from "next/image"
2
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import PlaceholderImage from "@modules/common/icons/placeholder-image"
5
+
6
+ type ShopByAgeProps = {
7
+ collections: HttpTypes.StoreCollection[]
8
+ }
9
+
10
+ /** Impulse-style promo tiles — large image blocks below the hero. */
11
+ const ShopByAge = ({ collections }: ShopByAgeProps) => {
12
+ if (!collections || collections.length === 0) {
13
+ return null
14
+ }
15
+
16
+ const tiles = [...collections].reverse().slice(0, 3)
17
+
18
+ return (
19
+ <section className="w-full bg-page-bg">
20
+ <div
21
+ className="mx-auto grid grid-cols-1 md:grid-cols-3 gap-0"
22
+ style={{ maxWidth: "var(--container-max)" }}
23
+ >
24
+ {tiles.map((collection) => {
25
+ const metadata = (collection as { metadata?: Record<string, string> })
26
+ ?.metadata
27
+ const image =
28
+ metadata?.image ||
29
+ (collection as { thumbnail?: string }).thumbnail ||
30
+ null
31
+ const subtitle = metadata?.collection_subtitle || metadata?.collection_age
32
+ const buttonLabel = metadata?.button_label || "Shop now"
33
+
34
+ return (
35
+ <LocalizedClientLink
36
+ key={collection.id}
37
+ href={`/store?collection=${collection.id}`}
38
+ data-ga-event="promo_tile_click"
39
+ data-ga-label={collection.title || "Promo tile"}
40
+ className="group relative block aspect-[4/5] md:aspect-[3/4] overflow-hidden"
41
+ >
42
+ {image ? (
43
+ <Image
44
+ src={image}
45
+ alt={collection.title || "Collection"}
46
+ fill
47
+ sizes="(max-width: 768px) 100vw, 33vw"
48
+ className="object-cover transition-transform duration-700 group-hover:scale-105"
49
+ />
50
+ ) : (
51
+ <div className="absolute inset-0 flex items-center justify-center bg-surface-muted">
52
+ <PlaceholderImage size={64} />
53
+ </div>
54
+ )}
55
+
56
+ <div
57
+ className="absolute inset-0"
58
+ style={{ background: "var(--gradient-hero)" }}
59
+ />
60
+
61
+ <div className="absolute inset-x-0 bottom-0 p-6 md:p-8 text-[var(--color-text-inverse)]">
62
+ {subtitle && (
63
+ <p className="text-[10px] md:text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] mb-2 opacity-90">
64
+ {subtitle}
65
+ </p>
66
+ )}
67
+ <h3 className="font-display text-xl md:text-2xl lg:text-3xl font-normal leading-heading mb-4">
68
+ {collection.title}
69
+ </h3>
70
+ <span className="inline-block border border-white text-white px-5 py-2.5 text-[10px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] group-hover:bg-white group-hover:text-brand-accent transition-colors duration-300">
71
+ {buttonLabel}
72
+ </span>
73
+ </div>
74
+ </LocalizedClientLink>
75
+ )
76
+ })}
77
+ </div>
78
+ </section>
79
+ )
80
+ }
81
+
82
+ export default ShopByAge
@@ -0,0 +1,92 @@
1
+ import Image from "next/image"
2
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import PlaceholderImage from "@modules/common/icons/placeholder-image"
5
+
6
+ type ShopByCategoryProps = {
7
+ categories: HttpTypes.StoreProductCategory[]
8
+ }
9
+
10
+ /** Impulse-style collection tiles — clean image grid with overlay titles. */
11
+ const ShopByCategory = ({ categories }: ShopByCategoryProps) => {
12
+ if (!categories || categories.length === 0) {
13
+ return null
14
+ }
15
+
16
+ const subcategories = categories
17
+ .filter((category) => category.parent_category_id)
18
+ .slice(0, 6)
19
+
20
+ if (subcategories.length === 0) {
21
+ return null
22
+ }
23
+
24
+ return (
25
+ <section className="w-full py-10 md:py-16 bg-page-bg">
26
+ <div
27
+ className="mx-auto px-4 sm:px-6 lg:px-8"
28
+ style={{ maxWidth: "var(--container-max)" }}
29
+ >
30
+ <h2 className="font-display text-2xl md:text-3xl text-heading text-center mb-8 md:mb-12">
31
+ Shop by category
32
+ </h2>
33
+
34
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
35
+ {subcategories.map((category) => {
36
+ const metadata = (category as { metadata?: Record<string, string> })
37
+ ?.metadata
38
+ const categoryImage = metadata?.category_image || null
39
+ const parentCategory = (
40
+ category as { parent_category?: { name?: string } }
41
+ )?.parent_category
42
+ const subtitle = parentCategory?.name || ""
43
+ const categoryUrl = category.id
44
+ ? `/store?category=${category.id}`
45
+ : "#"
46
+
47
+ return (
48
+ <LocalizedClientLink
49
+ key={category.id}
50
+ href={categoryUrl}
51
+ data-ga-event="shop_by_category_click"
52
+ data-ga-label={category.name || "Category"}
53
+ className="group relative block aspect-[3/4] overflow-hidden bg-surface-muted"
54
+ >
55
+ {categoryImage ? (
56
+ <Image
57
+ src={categoryImage}
58
+ alt={category.name || "Category"}
59
+ fill
60
+ sizes="(max-width: 768px) 50vw, 33vw"
61
+ className="object-cover transition-transform duration-500 group-hover:scale-105"
62
+ />
63
+ ) : (
64
+ <div className="absolute inset-0 flex items-center justify-center">
65
+ <PlaceholderImage size={48} />
66
+ </div>
67
+ )}
68
+
69
+ <div
70
+ className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors duration-300"
71
+ />
72
+
73
+ <div className="absolute inset-x-0 bottom-0 p-4 md:p-6 text-[var(--color-text-inverse)]">
74
+ {subtitle && (
75
+ <p className="text-[10px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] mb-1 opacity-90">
76
+ {subtitle}
77
+ </p>
78
+ )}
79
+ <h3 className="font-display text-base md:text-xl font-normal leading-tight">
80
+ {category.name}
81
+ </h3>
82
+ </div>
83
+ </LocalizedClientLink>
84
+ )
85
+ })}
86
+ </div>
87
+ </div>
88
+ </section>
89
+ )
90
+ }
91
+
92
+ export default ShopByCategory
@@ -0,0 +1,130 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+
5
+ interface Testimonial {
6
+ id: string
7
+ text: string
8
+ name: string
9
+ rating: number
10
+ avatar?: string
11
+ }
12
+
13
+ interface TestimonialsProps {
14
+ initialData?: {
15
+ title: string
16
+ testimonials: Testimonial[]
17
+ } | null
18
+ }
19
+
20
+ const defaultTestimonials: Testimonial[] = [
21
+ {
22
+ id: "1",
23
+ text: "Beautiful quality and fast shipping. Exactly what I was looking for — will definitely order again.",
24
+ name: "Sarah M.",
25
+ rating: 5,
26
+ },
27
+ {
28
+ id: "2",
29
+ text: "The site is easy to navigate and checkout was seamless. Products arrived well packaged.",
30
+ name: "James L.",
31
+ rating: 5,
32
+ },
33
+ {
34
+ id: "3",
35
+ text: "Great customer service and the returns process was hassle-free. Highly recommend.",
36
+ name: "Emily R.",
37
+ rating: 5,
38
+ },
39
+ ]
40
+
41
+ /** Impulse-style testimonial carousel — editorial serif quotes. */
42
+ const Testimonials = ({ initialData }: TestimonialsProps) => {
43
+ const [currentIndex, setCurrentIndex] = useState(0)
44
+ const title = initialData?.title || "What our customers say"
45
+ const testimonials =
46
+ initialData?.testimonials && initialData.testimonials.length > 0
47
+ ? initialData.testimonials
48
+ : defaultTestimonials
49
+
50
+ const [itemsPerView, setItemsPerView] = useState(1)
51
+
52
+ useEffect(() => {
53
+ const updateItemsPerView = () => {
54
+ if (window.innerWidth >= 1024) setItemsPerView(3)
55
+ else if (window.innerWidth >= 640) setItemsPerView(2)
56
+ else setItemsPerView(1)
57
+ }
58
+ updateItemsPerView()
59
+ window.addEventListener("resize", updateItemsPerView)
60
+ return () => window.removeEventListener("resize", updateItemsPerView)
61
+ }, [])
62
+
63
+ const visible = testimonials.slice(currentIndex, currentIndex + itemsPerView)
64
+ const padded =
65
+ visible.length < itemsPerView
66
+ ? [...visible, ...testimonials.slice(0, itemsPerView - visible.length)]
67
+ : visible
68
+
69
+ const hasMore = testimonials.length > itemsPerView
70
+
71
+ const next = () =>
72
+ setCurrentIndex((prev) => (prev + itemsPerView) % testimonials.length)
73
+ const prev = () =>
74
+ setCurrentIndex(
75
+ (prev) =>
76
+ (prev - itemsPerView + testimonials.length) % testimonials.length
77
+ )
78
+
79
+ return (
80
+ <section className="w-full py-10 md:py-16 bg-surface-muted">
81
+ <div
82
+ className="mx-auto px-4 sm:px-6 lg:px-8"
83
+ style={{ maxWidth: "var(--container-max)" }}
84
+ >
85
+ <h2 className="font-display text-2xl md:text-3xl text-heading text-center mb-8 md:mb-12">
86
+ {title}
87
+ </h2>
88
+
89
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
90
+ {padded.map((testimonial) => (
91
+ <blockquote
92
+ key={testimonial.id}
93
+ className="bg-page-bg p-6 md:p-8 text-center"
94
+ >
95
+ <p className="font-quote text-lg md:text-xl text-heading leading-relaxed mb-6 italic">
96
+ &ldquo;{testimonial.text}&rdquo;
97
+ </p>
98
+ <footer className="text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-[var(--color-text-muted)]">
99
+ — {testimonial.name}
100
+ </footer>
101
+ </blockquote>
102
+ ))}
103
+ </div>
104
+
105
+ {hasMore && (
106
+ <div className="flex justify-center gap-4 mt-8">
107
+ <button
108
+ type="button"
109
+ onClick={prev}
110
+ className="w-10 h-10 border border-[var(--color-border)] flex items-center justify-center text-heading hover:bg-brand-accent hover:text-white hover:border-brand-accent transition-colors"
111
+ aria-label="Previous testimonials"
112
+ >
113
+
114
+ </button>
115
+ <button
116
+ type="button"
117
+ onClick={next}
118
+ className="w-10 h-10 border border-brand-accent bg-brand-accent text-white flex items-center justify-center hover:bg-brand-accent-hover transition-colors"
119
+ aria-label="Next testimonials"
120
+ >
121
+
122
+ </button>
123
+ </div>
124
+ )}
125
+ </div>
126
+ </section>
127
+ )
128
+ }
129
+
130
+ export default Testimonials