@pradip1995/theme-sahsha 3.1.0
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.
- package/README.md +29 -0
- package/assets/hero-desktop.svg +10 -0
- package/assets/hero-mobile.svg +9 -0
- package/assets/logo.svg +3 -0
- package/package.json +60 -0
- package/src/blocks/home/Features/index.tsx +87 -0
- package/src/blocks/home/Hero/index.tsx +98 -0
- package/src/blocks/home/LovedByMoms/bestsellers-carousel.tsx +1 -0
- package/src/blocks/home/LovedByMoms/index.tsx +43 -0
- package/src/blocks/home/LovedByMoms/loved-by-moms-section.tsx +46 -0
- package/src/blocks/home/NewArrivals/index.tsx +91 -0
- package/src/blocks/home/PromotionalBanners/index.tsx +81 -0
- package/src/blocks/home/ShopByAge/collections-showcase-client.tsx +131 -0
- package/src/blocks/home/ShopByAge/collections-showcase-types.ts +4 -0
- package/src/blocks/home/ShopByAge/index.tsx +168 -0
- package/src/blocks/home/ShopByCategory/index.tsx +111 -0
- package/src/blocks/home/Testimonials/index.tsx +25 -0
- package/src/blocks/home/Testimonials/reviews-scroll.tsx +122 -0
- package/src/blocks/home/Testimonials/testimonials-client.tsx +127 -0
- package/src/blocks/home/WhyChooseUs/index.tsx +39 -0
- package/src/components/product-carousel.tsx +79 -0
- package/src/layouts/MainLayoutShell.tsx +14 -0
- package/src/primitives/Button.tsx +31 -0
- package/src/primitives/Card.tsx +32 -0
- package/src/primitives/index.ts +2 -0
- package/src/slots/account/ForgotPassword/index.tsx +1 -0
- package/src/slots/account/GoogleLogin/index.tsx +28 -0
- package/src/slots/account/Login/index.tsx +1 -0
- package/src/slots/account/LoginTemplate/index.tsx +12 -0
- package/src/slots/account/LoginTemplate/login-template-client.tsx +83 -0
- package/src/slots/account/Register/index.tsx +1 -0
- package/src/slots/cart/CartItem/index.tsx +11 -0
- package/src/slots/cart/CartSummary/index.tsx +8 -0
- package/src/slots/checkout/CheckoutForm/index.tsx +1 -0
- package/src/slots/checkout/CheckoutSummary/index.tsx +1 -0
- package/src/slots/layout/Footer/index.tsx +95 -0
- package/src/slots/layout/Nav/index.tsx +50 -0
- package/src/slots/layout/Nav/nav-categories-dropdown.tsx +74 -0
- package/src/slots/layout/Nav/nav-collections-dropdown.tsx +106 -0
- package/src/slots/layout/Nav/nav-header-content.tsx +165 -0
- package/src/slots/layout/Nav/nav-header-shell.tsx +47 -0
- package/src/slots/layout/Nav/nav-link-luxury.tsx +15 -0
- package/src/slots/layout/PromoBar/index.tsx +9 -0
- package/src/slots/layout/PromoBar/promo-bar-content.tsx +118 -0
- package/src/slots/order/OrderDetails/index.tsx +12 -0
- package/src/slots/product/ProductActions/ProductCTASection.tsx +232 -0
- package/src/slots/product/ProductActions/ProductDetailsSection.tsx +200 -0
- package/src/slots/product/ProductActions/ProductFeaturePanel.tsx +150 -0
- package/src/slots/product/ProductActions/ProductHighlightsSection.tsx +112 -0
- package/src/slots/product/ProductActions/ProductOptionsSection.tsx +215 -0
- package/src/slots/product/ProductActions/ProductPriceSection.tsx +53 -0
- package/src/slots/product/ProductActions/ProductTrustSection.tsx +84 -0
- package/src/slots/product/ProductActions/SizeChartPanel.tsx +93 -0
- package/src/slots/product/ProductActions/index.tsx +156 -0
- package/src/slots/product/ProductActions/product-metadata-fields.ts +503 -0
- package/src/slots/product/ProductActions/size-chart-data.ts +108 -0
- package/src/slots/product/ProductCard/index.tsx +258 -0
- package/src/slots/product/ProductInfo/index.tsx +35 -0
- package/src/templates/CollectionsPage/index.tsx +72 -0
- package/src/templates/StorePage/index.tsx +134 -0
- package/src/tokens/colors.ts +21 -0
- package/src/tokens/fonts.ts +16 -0
- package/src/tokens/index.ts +3 -0
- package/src/tokens/spacing.ts +9 -0
- package/src/tokens/theme.css +12754 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
5
|
+
import { HttpTypes } from "@medusajs/types"
|
|
6
|
+
import type { ProductCardRating } from "@core/types/product-card"
|
|
7
|
+
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
|
8
|
+
import ProductCard from "@theme/slots/product/ProductCard"
|
|
9
|
+
import type { CollectionTab } from "./collections-showcase-types"
|
|
10
|
+
|
|
11
|
+
type CollectionsShowcaseClientProps = {
|
|
12
|
+
tabs: CollectionTab[]
|
|
13
|
+
productsByCollection: Record<string, HttpTypes.StoreProduct[]>
|
|
14
|
+
region: HttpTypes.StoreRegion
|
|
15
|
+
ratings?: ProductCardRating[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getDefaultActiveTabId(tabs: CollectionTab[]) {
|
|
19
|
+
if (!tabs.length) {
|
|
20
|
+
return ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const middleIndex = Math.floor(tabs.length / 2)
|
|
24
|
+
return tabs[middleIndex]?.id ?? tabs[0].id
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CollectionsShowcaseClient = ({
|
|
28
|
+
tabs,
|
|
29
|
+
productsByCollection,
|
|
30
|
+
region,
|
|
31
|
+
ratings,
|
|
32
|
+
}: CollectionsShowcaseClientProps) => {
|
|
33
|
+
const viewportRef = useRef<HTMLDivElement>(null)
|
|
34
|
+
const [activeId, setActiveId] = useState(() => getDefaultActiveTabId(tabs))
|
|
35
|
+
|
|
36
|
+
const activeProducts = productsByCollection[activeId] ?? []
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const viewport = viewportRef.current
|
|
40
|
+
if (viewport) {
|
|
41
|
+
viewport.scrollLeft = 0
|
|
42
|
+
}
|
|
43
|
+
}, [activeId])
|
|
44
|
+
|
|
45
|
+
const scrollByPage = (direction: "prev" | "next") => {
|
|
46
|
+
const viewport = viewportRef.current
|
|
47
|
+
if (!viewport) return
|
|
48
|
+
|
|
49
|
+
const amount = Math.max(viewport.clientWidth * 0.85, 280)
|
|
50
|
+
viewport.scrollBy({
|
|
51
|
+
left: direction === "next" ? amount : -amount,
|
|
52
|
+
behavior: "smooth",
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!tabs.length) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<nav className="collections-showcase__tabs" aria-label="Collections">
|
|
63
|
+
{tabs.map((tab) => {
|
|
64
|
+
const isActive = tab.id === activeId
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<button
|
|
68
|
+
key={tab.id}
|
|
69
|
+
type="button"
|
|
70
|
+
className={`collections-showcase__tab${isActive ? " is-active" : ""}`}
|
|
71
|
+
aria-current={isActive ? "true" : undefined}
|
|
72
|
+
onClick={() => setActiveId(tab.id)}
|
|
73
|
+
>
|
|
74
|
+
{tab.title}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
})}
|
|
78
|
+
</nav>
|
|
79
|
+
|
|
80
|
+
<div className="collections-showcase__slider">
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className="collections-showcase__nav collections-showcase__nav--prev"
|
|
84
|
+
onClick={() => scrollByPage("prev")}
|
|
85
|
+
aria-label="Previous products"
|
|
86
|
+
>
|
|
87
|
+
<ChevronLeft size={18} strokeWidth={1.5} />
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<div ref={viewportRef} className="collections-showcase__viewport">
|
|
91
|
+
<div className="collections-showcase__track">
|
|
92
|
+
{activeProducts.length > 0 ? (
|
|
93
|
+
activeProducts.map((product) => (
|
|
94
|
+
<div key={product.id} className="collections-showcase__slide">
|
|
95
|
+
<ProductCard
|
|
96
|
+
product={product}
|
|
97
|
+
region={region}
|
|
98
|
+
rating={ratings?.find((r) => r.product_id === product.id)}
|
|
99
|
+
className="h-full"
|
|
100
|
+
imageClassName="shop-grid__card-image"
|
|
101
|
+
quickAddClassName="product-card__quick-add--hero"
|
|
102
|
+
showWishlistIcon
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
))
|
|
106
|
+
) : (
|
|
107
|
+
<p className="collections-showcase__empty">No products in this collection yet.</p>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className="collections-showcase__nav collections-showcase__nav--next"
|
|
115
|
+
onClick={() => scrollByPage("next")}
|
|
116
|
+
aria-label="Next products"
|
|
117
|
+
>
|
|
118
|
+
<ChevronRight size={18} strokeWidth={1.5} />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="collections-showcase__footer">
|
|
123
|
+
<LocalizedClientLink href="/collections" className="collections-showcase__view-all">
|
|
124
|
+
View all
|
|
125
|
+
</LocalizedClientLink>
|
|
126
|
+
</div>
|
|
127
|
+
</>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default CollectionsShowcaseClient
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { listCollections } from "@core/data/collections"
|
|
2
|
+
import { listProducts } from "@core/data/products"
|
|
3
|
+
import { fetchRatings } from "@core/data/reviews"
|
|
4
|
+
import { getRegion } from "@core/data/regions"
|
|
5
|
+
import type { ProductCardRating } from "@core/types/product-card"
|
|
6
|
+
import { HttpTypes } from "@medusajs/types"
|
|
7
|
+
import { storefrontConfig } from "@storefront-config"
|
|
8
|
+
import CollectionsShowcaseClient from "./collections-showcase-client"
|
|
9
|
+
import type { CollectionTab } from "./collections-showcase-types"
|
|
10
|
+
|
|
11
|
+
type ShopByAgeProps = {
|
|
12
|
+
collections: HttpTypes.StoreCollection[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FEATURED_TAB_COUNT = 3
|
|
16
|
+
const PRODUCTS_PER_COLLECTION = 12
|
|
17
|
+
|
|
18
|
+
function normalizeCollectionKey(value: string) {
|
|
19
|
+
return value.toLowerCase().replace(/[\s_-]+/g, "")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getCollectionMetadata(collection: HttpTypes.StoreCollection) {
|
|
23
|
+
return (collection.metadata || {}) as Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isHomeFeatured(collection: HttpTypes.StoreCollection) {
|
|
27
|
+
const value = getCollectionMetadata(collection).home_featured
|
|
28
|
+
return value === true || value === "true" || value === "1"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getHomeSort(collection: HttpTypes.StoreCollection) {
|
|
32
|
+
const raw = getCollectionMetadata(collection).home_sort
|
|
33
|
+
const num = Number.parseInt(String(raw ?? ""), 10)
|
|
34
|
+
return Number.isFinite(num) ? num : 999
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickFeaturedFromMetadata(collections: HttpTypes.StoreCollection[]) {
|
|
38
|
+
return collections
|
|
39
|
+
.filter(isHomeFeatured)
|
|
40
|
+
.sort((a, b) => getHomeSort(a) - getHomeSort(b))
|
|
41
|
+
.slice(0, FEATURED_TAB_COUNT)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickFeaturedFromConfig(collections: HttpTypes.StoreCollection[]) {
|
|
45
|
+
const configuredHandles = storefrontConfig.homeFeaturedCollectionHandles ?? []
|
|
46
|
+
if (!configuredHandles.length) {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const picked: HttpTypes.StoreCollection[] = []
|
|
51
|
+
|
|
52
|
+
for (const handle of configuredHandles) {
|
|
53
|
+
const key = normalizeCollectionKey(handle)
|
|
54
|
+
const found = collections.find((collection) => {
|
|
55
|
+
const byHandle = normalizeCollectionKey(collection.handle || "")
|
|
56
|
+
const byTitle = normalizeCollectionKey(collection.title || "")
|
|
57
|
+
return byHandle === key || byTitle === key
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (found && !picked.some((item) => item.id === found.id)) {
|
|
61
|
+
picked.push(found)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return picked
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pickFeaturedCollections(collections: HttpTypes.StoreCollection[]) {
|
|
69
|
+
const fromMetadata = pickFeaturedFromMetadata(collections)
|
|
70
|
+
if (fromMetadata.length > 0) {
|
|
71
|
+
return fromMetadata
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fromConfig = pickFeaturedFromConfig(collections)
|
|
75
|
+
if (fromConfig.length > 0) {
|
|
76
|
+
return fromConfig
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return collections.slice(0, FEATURED_TAB_COUNT)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ShopByAge = async ({ collections: fallbackCollections }: ShopByAgeProps) => {
|
|
83
|
+
let collections = fallbackCollections
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = await listCollections({
|
|
87
|
+
limit: "100",
|
|
88
|
+
fields: "id,handle,title,metadata",
|
|
89
|
+
})
|
|
90
|
+
if (result.collections.length > 0) {
|
|
91
|
+
collections = result.collections
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
collections = fallbackCollections
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!collections || collections.length === 0) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const countryCode = process.env.NEXT_PUBLIC_DEFAULT_REGION ?? "in"
|
|
102
|
+
const region = await getRegion(countryCode)
|
|
103
|
+
|
|
104
|
+
if (!region) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ratings = (await fetchRatings().catch(() => [])) as ProductCardRating[]
|
|
109
|
+
const featuredCollections = pickFeaturedCollections(collections)
|
|
110
|
+
const tabs: CollectionTab[] = featuredCollections.map((collection) => ({
|
|
111
|
+
id: collection.id,
|
|
112
|
+
title: collection.title || "Collection",
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
const productGroups = await Promise.all(
|
|
116
|
+
featuredCollections.map(async (collection) => {
|
|
117
|
+
try {
|
|
118
|
+
const { response } = await listProducts({
|
|
119
|
+
regionId: region.id,
|
|
120
|
+
queryParams: {
|
|
121
|
+
collection_id: collection.id,
|
|
122
|
+
limit: PRODUCTS_PER_COLLECTION,
|
|
123
|
+
fields: "*variants.calculated_price",
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
collectionId: collection.id,
|
|
129
|
+
products: response.products ?? [],
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
return {
|
|
133
|
+
collectionId: collection.id,
|
|
134
|
+
products: [] as HttpTypes.StoreProduct[],
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const productsByCollection = productGroups.reduce<
|
|
141
|
+
Record<string, HttpTypes.StoreProduct[]>
|
|
142
|
+
>((acc, group) => {
|
|
143
|
+
acc[group.collectionId] = group.products
|
|
144
|
+
return acc
|
|
145
|
+
}, {})
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<section className="collections-showcase w-full bg-page-bg">
|
|
149
|
+
<div
|
|
150
|
+
className="collections-showcase__inner mx-auto"
|
|
151
|
+
style={{ maxWidth: "var(--container-max)" }}
|
|
152
|
+
>
|
|
153
|
+
<header className="collections-showcase__header">
|
|
154
|
+
<p className="collections-showcase__eyebrow">What's new</p>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
<CollectionsShowcaseClient
|
|
158
|
+
tabs={tabs}
|
|
159
|
+
productsByCollection={productsByCollection}
|
|
160
|
+
region={region}
|
|
161
|
+
ratings={ratings}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</section>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default ShopByAge
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
import {
|
|
6
|
+
getCategoryImage,
|
|
7
|
+
getCategoryProductCount,
|
|
8
|
+
} from "@lib/category-collection-images"
|
|
9
|
+
|
|
10
|
+
type ShopByCategoryProps = {
|
|
11
|
+
categories: HttpTypes.StoreProductCategory[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ShopByCategory = ({ categories }: ShopByCategoryProps) => {
|
|
15
|
+
if (!categories || categories.length === 0) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parentCategories = categories.filter(
|
|
20
|
+
(category) => !category.parent_category_id
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if (parentCategories.length === 0) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const categoryGroups: HttpTypes.StoreProductCategory[][] = []
|
|
28
|
+
for (let i = 0; i < parentCategories.length; i += 5) {
|
|
29
|
+
categoryGroups.push(parentCategories.slice(i, i + 5))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section className="shop-by-category w-full bg-white py-8 sm:py-9 md:py-10 font-[family-name:var(--sf-font-family)]">
|
|
34
|
+
<div
|
|
35
|
+
className="shop-by-category__inner"
|
|
36
|
+
style={{ maxWidth: "var(--container-max)" }}
|
|
37
|
+
>
|
|
38
|
+
<div className="shop-by-category__header">
|
|
39
|
+
<h2 className="font-display text-2xl md:text-3xl text-heading">
|
|
40
|
+
Shop by category
|
|
41
|
+
</h2>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="shop-by-category__groups">
|
|
45
|
+
{categoryGroups.map((group, groupIndex) => (
|
|
46
|
+
<div key={groupIndex} className="shop-by-category__grid">
|
|
47
|
+
{group.map((category, index) => {
|
|
48
|
+
const categoryImage = getCategoryImage(category)
|
|
49
|
+
const productCount = getCategoryProductCount(category)
|
|
50
|
+
const categoryUrl = category.id
|
|
51
|
+
? `/store?category=${category.id}`
|
|
52
|
+
: "#"
|
|
53
|
+
const rowClass =
|
|
54
|
+
index < 3
|
|
55
|
+
? "shop-by-category__card--top"
|
|
56
|
+
: "shop-by-category__card--bottom"
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<LocalizedClientLink
|
|
60
|
+
key={category.id}
|
|
61
|
+
href={categoryUrl}
|
|
62
|
+
data-ga-event="shop_by_category_click"
|
|
63
|
+
data-ga-label={category.name || "Category"}
|
|
64
|
+
className={`shop-by-category__card group ${rowClass}`}
|
|
65
|
+
>
|
|
66
|
+
<div className="shop-by-category__frame">
|
|
67
|
+
{categoryImage ? (
|
|
68
|
+
<Image
|
|
69
|
+
src={categoryImage}
|
|
70
|
+
alt={category.name || "Category"}
|
|
71
|
+
fill
|
|
72
|
+
sizes="(max-width: 1024px) 50vw, 33vw"
|
|
73
|
+
className="shop-by-category__image"
|
|
74
|
+
/>
|
|
75
|
+
) : (
|
|
76
|
+
<div className="shop-by-category__placeholder">
|
|
77
|
+
<PlaceholderImage size={48} />
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="shop-by-category__overlay" aria-hidden />
|
|
82
|
+
|
|
83
|
+
{productCount > 0 && (
|
|
84
|
+
<p className="shop-by-category__count">
|
|
85
|
+
{productCount}{" "}
|
|
86
|
+
{productCount === 1 ? "Product" : "Products"}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<div className="shop-by-category__content">
|
|
91
|
+
<h3 className="shop-by-category__name">{category.name}</h3>
|
|
92
|
+
<p className="shop-by-category__cta">
|
|
93
|
+
View the collection
|
|
94
|
+
<span className="shop-by-category__cta-arrow" aria-hidden>
|
|
95
|
+
›
|
|
96
|
+
</span>
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</LocalizedClientLink>
|
|
101
|
+
)
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default ShopByCategory
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { enrichTestimonialsData } from "@lib/data/testimonials"
|
|
2
|
+
import TestimonialsClient from "./testimonials-client"
|
|
3
|
+
|
|
4
|
+
interface TestimonialsProps {
|
|
5
|
+
initialData?: {
|
|
6
|
+
title: string
|
|
7
|
+
eyebrow?: string
|
|
8
|
+
description?: string
|
|
9
|
+
testimonials: Array<{
|
|
10
|
+
id: string
|
|
11
|
+
text: string
|
|
12
|
+
name: string
|
|
13
|
+
rating: number
|
|
14
|
+
avatar?: string
|
|
15
|
+
}>
|
|
16
|
+
} | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Testimonials = async ({ initialData }: TestimonialsProps) => {
|
|
20
|
+
const enrichedData = await enrichTestimonialsData(initialData ?? null)
|
|
21
|
+
|
|
22
|
+
return <TestimonialsClient initialData={enrichedData} />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default Testimonials
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type CSSProperties } from "react"
|
|
4
|
+
import Image from "next/image"
|
|
5
|
+
import PlaceholderImage from "@modules/common/icons/placeholder-image"
|
|
6
|
+
|
|
7
|
+
export type ReviewItem = {
|
|
8
|
+
id: string
|
|
9
|
+
text: string
|
|
10
|
+
name: string
|
|
11
|
+
rating: number
|
|
12
|
+
avatar?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ReviewsScrollProps = {
|
|
16
|
+
items: ReviewItem[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CardStars({ rating }: { rating: number }) {
|
|
20
|
+
const clamped = Math.max(0, Math.min(5, rating))
|
|
21
|
+
const fullStars = Math.floor(clamped)
|
|
22
|
+
const hasHalf = clamped % 1 >= 0.5
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className="customer-reviews__card-stars"
|
|
27
|
+
aria-label={`${clamped} out of 5 stars`}
|
|
28
|
+
>
|
|
29
|
+
{Array.from({ length: 5 }, (_, index) => {
|
|
30
|
+
const filled = index < fullStars || (index === fullStars && hasHalf)
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
key={index}
|
|
35
|
+
className={
|
|
36
|
+
filled
|
|
37
|
+
? "customer-reviews__star customer-reviews__star--filled"
|
|
38
|
+
: "customer-reviews__star"
|
|
39
|
+
}
|
|
40
|
+
>
|
|
41
|
+
★
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
})}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ReviewCard({ item }: { item: ReviewItem }) {
|
|
50
|
+
return (
|
|
51
|
+
<article className="customer-reviews__card">
|
|
52
|
+
<div className="customer-reviews__card-media">
|
|
53
|
+
{item.avatar ? (
|
|
54
|
+
<Image
|
|
55
|
+
src={item.avatar}
|
|
56
|
+
alt={item.name}
|
|
57
|
+
fill
|
|
58
|
+
sizes="(max-width: 768px) 80vw, 320px"
|
|
59
|
+
className="customer-reviews__card-image"
|
|
60
|
+
unoptimized
|
|
61
|
+
/>
|
|
62
|
+
) : (
|
|
63
|
+
<div className="customer-reviews__card-placeholder">
|
|
64
|
+
<PlaceholderImage size={36} />
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="customer-reviews__card-body">
|
|
70
|
+
<p className="customer-reviews__card-quote">{item.text}</p>
|
|
71
|
+
<CardStars rating={item.rating} />
|
|
72
|
+
|
|
73
|
+
<p className="customer-reviews__card-meta">
|
|
74
|
+
<span className="customer-reviews__card-name">{item.name}</span>
|
|
75
|
+
<span className="customer-reviews__card-divider" aria-hidden>
|
|
76
|
+
|
|
|
77
|
+
</span>
|
|
78
|
+
<span className="customer-reviews__card-verified">Verified</span>
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
</article>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ReviewsScroll = ({ items }: ReviewsScrollProps) => {
|
|
86
|
+
const [reduceMotion, setReduceMotion] = useState(false)
|
|
87
|
+
const loopedItems = items.length > 1 ? [...items, ...items] : items
|
|
88
|
+
const canAnimate = !reduceMotion && items.length > 1
|
|
89
|
+
const duration = Math.max(items.length * 7, 18)
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
93
|
+
const update = () => setReduceMotion(media.matches)
|
|
94
|
+
update()
|
|
95
|
+
media.addEventListener("change", update)
|
|
96
|
+
return () => media.removeEventListener("change", update)
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="customer-reviews__scroll">
|
|
101
|
+
<div className="customer-reviews__fade customer-reviews__fade--top" aria-hidden />
|
|
102
|
+
<div className="customer-reviews__fade customer-reviews__fade--bottom" aria-hidden />
|
|
103
|
+
|
|
104
|
+
<div className="customer-reviews__viewport">
|
|
105
|
+
<div
|
|
106
|
+
className={`customer-reviews__track${canAnimate ? " customer-reviews__track--animate" : ""}`}
|
|
107
|
+
style={
|
|
108
|
+
canAnimate
|
|
109
|
+
? ({ "--reviews-duration": `${duration}s` } as CSSProperties)
|
|
110
|
+
: undefined
|
|
111
|
+
}
|
|
112
|
+
>
|
|
113
|
+
{loopedItems.map((item, index) => (
|
|
114
|
+
<ReviewCard key={`${item.id}-${index}`} item={item} />
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default ReviewsScroll
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import ReviewsScroll, { type ReviewItem } from "./reviews-scroll"
|
|
4
|
+
|
|
5
|
+
interface TestimonialsClientProps {
|
|
6
|
+
initialData?: {
|
|
7
|
+
title: string
|
|
8
|
+
eyebrow?: string
|
|
9
|
+
description?: string
|
|
10
|
+
testimonials: ReviewItem[]
|
|
11
|
+
} | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_EYEBROW = "Trusted by Those Who Lounge in Luxury"
|
|
15
|
+
const DEFAULT_TITLE_LINE_1 = "What Our Customers"
|
|
16
|
+
const DEFAULT_TITLE_LINE_2 = "Are Saying"
|
|
17
|
+
const DEFAULT_DESCRIPTION =
|
|
18
|
+
"Crafted with the finest materials and designed for the modern woman, our pieces promise unparalleled comfort without compromising on style. Join the community of women who have transformed their at-home attire into statements of sophistication"
|
|
19
|
+
|
|
20
|
+
const defaultTestimonials: ReviewItem[] = [
|
|
21
|
+
{
|
|
22
|
+
id: "1",
|
|
23
|
+
text: "Absolutely love the quality! The rompers are so soft, and the colors stay vibrant even after multiple washes.",
|
|
24
|
+
name: "Priya Sharma",
|
|
25
|
+
rating: 5,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "2",
|
|
29
|
+
text: "The sizing guide was perfect. Shipping was super fast — received it in 2 days. Packaging was cute and sustainable.",
|
|
30
|
+
name: "Rahul Mehta",
|
|
31
|
+
rating: 5,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "3",
|
|
35
|
+
text: "Finally found trendy clothes for my toddler that are comfortable. ChocoMelon is my go-to for birthday outfits now!",
|
|
36
|
+
name: "Anita Kulkarni",
|
|
37
|
+
rating: 5,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "4",
|
|
41
|
+
text: "Best quality kids clothing I have ever bought. The fabric is soft and the designs are adorable. Highly recommend!",
|
|
42
|
+
name: "Sneha Patel",
|
|
43
|
+
rating: 5,
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
function HeaderStars() {
|
|
48
|
+
return (
|
|
49
|
+
<div className="customer-reviews__header-stars" aria-label="5 out of 5 stars">
|
|
50
|
+
{Array.from({ length: 5 }, (_, index) => (
|
|
51
|
+
<span
|
|
52
|
+
key={index}
|
|
53
|
+
className="customer-reviews__star customer-reviews__star--filled"
|
|
54
|
+
>
|
|
55
|
+
★
|
|
56
|
+
</span>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ReviewTitle({ title }: { title: string }) {
|
|
63
|
+
const normalized = title.trim()
|
|
64
|
+
const upper = normalized.toUpperCase()
|
|
65
|
+
|
|
66
|
+
if (upper === "WHAT OUR CUSTOMERS ARE SAYING") {
|
|
67
|
+
return (
|
|
68
|
+
<h2 className="customer-reviews__title">
|
|
69
|
+
<span className="customer-reviews__title-line">{DEFAULT_TITLE_LINE_1}</span>
|
|
70
|
+
<span className="customer-reviews__title-line">{DEFAULT_TITLE_LINE_2}</span>
|
|
71
|
+
</h2>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const areIndex = upper.indexOf(" ARE ")
|
|
76
|
+
if (areIndex > 0) {
|
|
77
|
+
return (
|
|
78
|
+
<h2 className="customer-reviews__title">
|
|
79
|
+
<span>{normalized.slice(0, areIndex + 1)}</span>
|
|
80
|
+
<span>{normalized.slice(areIndex + 1).trim()}</span>
|
|
81
|
+
</h2>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return <h2 className="customer-reviews__title">{normalized}</h2>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const TestimonialsClient = ({ initialData }: TestimonialsClientProps) => {
|
|
89
|
+
const title = initialData?.title || `${DEFAULT_TITLE_LINE_1} ${DEFAULT_TITLE_LINE_2}`
|
|
90
|
+
const eyebrow = initialData?.eyebrow || DEFAULT_EYEBROW
|
|
91
|
+
const description = initialData?.description || DEFAULT_DESCRIPTION
|
|
92
|
+
const testimonials = (
|
|
93
|
+
initialData?.testimonials && initialData.testimonials.length > 0
|
|
94
|
+
? initialData.testimonials
|
|
95
|
+
: defaultTestimonials
|
|
96
|
+
).map((testimonial, index) => ({
|
|
97
|
+
...testimonial,
|
|
98
|
+
id: testimonial.id || `testimonial-${index}`,
|
|
99
|
+
rating: Number.isFinite(testimonial.rating) ? testimonial.rating : 5,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
if (testimonials.length === 0) {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<section className="customer-reviews w-full bg-white">
|
|
108
|
+
<div className="customer-reviews__inner">
|
|
109
|
+
<div className="customer-reviews__layout">
|
|
110
|
+
<div className="customer-reviews__intro">
|
|
111
|
+
<div className="customer-reviews__intro-top">
|
|
112
|
+
<HeaderStars />
|
|
113
|
+
<p className="customer-reviews__eyebrow">{eyebrow}</p>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<ReviewTitle title={title} />
|
|
117
|
+
<p className="customer-reviews__description">{description}</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<ReviewsScroll items={testimonials} />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default TestimonialsClient
|