@pradip1995/segment-product-reviews 0.2.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pradip1995/segment-product-reviews",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "src/segment.css"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./manifest": "./src/manifest.ts"
17
+ },
18
+ "peerDependencies": {
19
+ "@medusajs/types": "^2.0.0",
20
+ "@pradip1995/plugin-sdk": "^0.2.0",
21
+ "react": ">=19",
22
+ "react-dom": ">=19",
23
+ "next": ">=15"
24
+ },
25
+ "dependencies": {
26
+ "@pradip1995/segment-primitives": "0.3.0",
27
+ "@pradip1995/segment-tokens": "0.3.2"
28
+ },
29
+ "devDependencies": {
30
+ "@medusajs/types": "^2.0.0",
31
+ "@pradip1995/plugin-sdk": "^0.2.0",
32
+ "@types/react": "^19",
33
+ "react": "19.0.3",
34
+ "typescript": "^5.7.2"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "tsc --noEmit"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "product-reviews",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "product",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1,135 @@
1
+ .product-reviews {
2
+ padding: 2rem 0;
3
+ border-top: 1px solid var(--color-border);
4
+ }
5
+
6
+ .product-reviews__header {
7
+ display: flex;
8
+ justify-content: space-between;
9
+ gap: 1rem;
10
+ align-items: flex-start;
11
+ margin-bottom: 1.5rem;
12
+ }
13
+
14
+ .product-reviews__title {
15
+ font-size: 1.5rem;
16
+ font-weight: 700;
17
+ color: var(--color-text-heading);
18
+ }
19
+
20
+ .product-reviews__summary {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 0.5rem;
24
+ margin-top: 0.5rem;
25
+ }
26
+
27
+ .product-reviews__star--filled {
28
+ color: var(--color-brand-accent);
29
+ }
30
+
31
+ .product-reviews__list {
32
+ list-style: none;
33
+ padding: 0;
34
+ margin: 0;
35
+ display: grid;
36
+ gap: 1.25rem;
37
+ }
38
+
39
+ .product-reviews__item {
40
+ padding-bottom: 1.25rem;
41
+ border-bottom: 1px solid var(--color-border);
42
+ }
43
+
44
+ .product-reviews__item-header {
45
+ display: flex;
46
+ flex-wrap: wrap;
47
+ gap: 0.5rem 1rem;
48
+ align-items: center;
49
+ margin-bottom: 0.5rem;
50
+ }
51
+
52
+ .product-reviews__reviewer {
53
+ font-weight: 600;
54
+ }
55
+
56
+ .product-reviews__date {
57
+ color: var(--color-text-muted);
58
+ font-size: 0.875rem;
59
+ }
60
+
61
+ .product-reviews__review-title {
62
+ font-weight: 600;
63
+ margin-bottom: 0.25rem;
64
+ }
65
+
66
+ .product-reviews__review-body {
67
+ color: var(--color-text-body);
68
+ line-height: 1.6;
69
+ }
70
+
71
+ .product-reviews__modal-backdrop {
72
+ position: fixed;
73
+ inset: 0;
74
+ background: rgba(0, 0, 0, 0.45);
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ z-index: 50;
79
+ padding: 1rem;
80
+ }
81
+
82
+ .product-reviews__modal {
83
+ width: min(100%, 28rem);
84
+ background: var(--color-surface);
85
+ border-radius: 1rem;
86
+ padding: 1.5rem;
87
+ }
88
+
89
+ .product-reviews__form {
90
+ display: grid;
91
+ gap: 1rem;
92
+ }
93
+
94
+ .product-reviews__rating-input {
95
+ display: flex;
96
+ gap: 0.25rem;
97
+ }
98
+
99
+ .product-reviews__rating-btn {
100
+ background: none;
101
+ border: none;
102
+ font-size: 1.5rem;
103
+ color: var(--color-border);
104
+ }
105
+
106
+ .product-reviews__rating-btn--active {
107
+ color: var(--color-brand-accent);
108
+ }
109
+
110
+ .product-reviews__field {
111
+ display: grid;
112
+ gap: 0.375rem;
113
+ }
114
+
115
+ .product-reviews__field input,
116
+ .product-reviews__field textarea {
117
+ width: 100%;
118
+ border: 1px solid var(--color-border);
119
+ border-radius: 0.75rem;
120
+ padding: 0.75rem 1rem;
121
+ }
122
+
123
+ .product-reviews__modal-actions {
124
+ display: flex;
125
+ justify-content: flex-end;
126
+ gap: 0.75rem;
127
+ }
128
+
129
+ .product-reviews__status--success {
130
+ color: #059669;
131
+ }
132
+
133
+ .product-reviews__status--error {
134
+ color: #e11d48;
135
+ }
@@ -0,0 +1,233 @@
1
+ "use client"
2
+
3
+ import "./segment.css"
4
+ import { FormEvent, useMemo, useState } from "react"
5
+ import type { HttpTypes } from "@medusajs/types"
6
+
7
+ type Review = {
8
+ id?: string
9
+ rating: number
10
+ title?: string | null
11
+ description?: string | null
12
+ created_at?: string | null
13
+ customer?: {
14
+ first_name?: string | null
15
+ last_name?: string | null
16
+ email?: string | null
17
+ } | null
18
+ }
19
+
20
+ type ReviewSummary = {
21
+ average_rating?: number
22
+ total_reviews?: number
23
+ }
24
+
25
+ export type ProductReviewsData = {
26
+ reviews?: Review[]
27
+ ratingSummary?: ReviewSummary | null
28
+ }
29
+
30
+ function StarRating({ rating, size = "text-lg" }: { rating: number; size?: string }) {
31
+ const fullStars = Math.floor(rating)
32
+ return (
33
+ <>
34
+ {Array.from({ length: 5 }).map((_, i) => (
35
+ <span key={i} className={`product-reviews__star ${i < fullStars ? "product-reviews__star--filled" : ""} ${size}`}>
36
+ {i < fullStars ? "★" : "☆"}
37
+ </span>
38
+ ))}
39
+ </>
40
+ )
41
+ }
42
+
43
+ function formatDate(dateString?: string | null) {
44
+ if (!dateString) return ""
45
+ try {
46
+ return new Date(dateString).toLocaleDateString()
47
+ } catch {
48
+ return ""
49
+ }
50
+ }
51
+
52
+ function reviewerName(review: Review) {
53
+ const first = review.customer?.first_name || ""
54
+ const last = review.customer?.last_name || ""
55
+ const full = `${first} ${last}`.trim()
56
+ return full || review.customer?.email || "Anonymous"
57
+ }
58
+
59
+ export default function ProductReviews({
60
+ product,
61
+ reviewData,
62
+ customer,
63
+ title = "Customer reviews",
64
+ onSubmitReview,
65
+ onLoginRequired,
66
+ }: {
67
+ product?: HttpTypes.StoreProduct | null
68
+ reviewData?: ProductReviewsData | null
69
+ customer?: HttpTypes.StoreCustomer | null
70
+ title?: string
71
+ onSubmitReview?: (input: {
72
+ productId: string
73
+ rating: number
74
+ title: string
75
+ description: string
76
+ }) => Promise<{ success: boolean; error?: string }>
77
+ onLoginRequired?: () => void
78
+ }) {
79
+ const reviews = reviewData?.reviews || []
80
+ const [isModalOpen, setIsModalOpen] = useState(false)
81
+ const [rating, setRating] = useState(0)
82
+ const [hoveredRating, setHoveredRating] = useState(0)
83
+ const [reviewTitle, setReviewTitle] = useState("")
84
+ const [reviewBody, setReviewBody] = useState("")
85
+ const [submitStatus, setSubmitStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
86
+ const [submitMessage, setSubmitMessage] = useState("")
87
+
88
+ const { averageRating, totalReviews } = useMemo(() => {
89
+ const apiRating = reviewData?.ratingSummary?.average_rating
90
+ const apiTotal = reviewData?.ratingSummary?.total_reviews
91
+ if (apiRating && apiTotal) {
92
+ return { averageRating: apiRating, totalReviews: apiTotal }
93
+ }
94
+ if (reviews.length === 0) return { averageRating: 0, totalReviews: 0 }
95
+ const total = reviews.reduce((sum, review) => sum + (review.rating || 0), 0)
96
+ return { averageRating: total / reviews.length, totalReviews: reviews.length }
97
+ }, [reviewData?.ratingSummary, reviews])
98
+
99
+ const handleWriteReview = () => {
100
+ if (!customer) {
101
+ onLoginRequired?.()
102
+ return
103
+ }
104
+ setIsModalOpen(true)
105
+ }
106
+
107
+ const handleSubmit = async (e: FormEvent) => {
108
+ e.preventDefault()
109
+ if (!product?.id || !onSubmitReview || rating < 1) return
110
+
111
+ setSubmitStatus("loading")
112
+ setSubmitMessage("")
113
+
114
+ const result = await onSubmitReview({
115
+ productId: product.id,
116
+ rating,
117
+ title: reviewTitle,
118
+ description: reviewBody,
119
+ })
120
+
121
+ if (result.success) {
122
+ setSubmitStatus("success")
123
+ setSubmitMessage("Thank you for your review!")
124
+ setReviewTitle("")
125
+ setReviewBody("")
126
+ setRating(0)
127
+ setTimeout(() => {
128
+ setIsModalOpen(false)
129
+ setSubmitStatus("idle")
130
+ setSubmitMessage("")
131
+ }, 1500)
132
+ } else {
133
+ setSubmitStatus("error")
134
+ setSubmitMessage(result.error || "Failed to submit review.")
135
+ }
136
+ }
137
+
138
+ return (
139
+ <section className="product-reviews" aria-label={title}>
140
+ <div className="product-reviews__header">
141
+ <div>
142
+ <h2 className="product-reviews__title">{title}</h2>
143
+ {totalReviews > 0 ? (
144
+ <div className="product-reviews__summary">
145
+ <StarRating rating={averageRating} size="text-2xl" />
146
+ <span className="product-reviews__average">{averageRating.toFixed(1)}</span>
147
+ <span className="product-reviews__count">({totalReviews} reviews)</span>
148
+ </div>
149
+ ) : (
150
+ <p className="product-reviews__empty">No reviews yet.</p>
151
+ )}
152
+ </div>
153
+ {onSubmitReview ? (
154
+ <button type="button" onClick={handleWriteReview} className="btn-primary product-reviews__write-btn">
155
+ Write a review
156
+ </button>
157
+ ) : null}
158
+ </div>
159
+
160
+ {reviews.length > 0 ? (
161
+ <ul className="product-reviews__list">
162
+ {reviews.map((review, index) => (
163
+ <li key={review.id || `${reviewerName(review)}-${index}`} className="product-reviews__item">
164
+ <div className="product-reviews__item-header">
165
+ <StarRating rating={review.rating} />
166
+ <span className="product-reviews__reviewer">{reviewerName(review)}</span>
167
+ {review.created_at ? (
168
+ <span className="product-reviews__date">{formatDate(review.created_at)}</span>
169
+ ) : null}
170
+ </div>
171
+ {review.title ? <h3 className="product-reviews__review-title">{review.title}</h3> : null}
172
+ {review.description ? <p className="product-reviews__review-body">{review.description}</p> : null}
173
+ </li>
174
+ ))}
175
+ </ul>
176
+ ) : null}
177
+
178
+ {isModalOpen ? (
179
+ <div className="product-reviews__modal-backdrop" role="presentation" onClick={() => setIsModalOpen(false)}>
180
+ <div
181
+ className="product-reviews__modal"
182
+ role="dialog"
183
+ aria-modal="true"
184
+ aria-label="Write a review"
185
+ onClick={(e) => e.stopPropagation()}
186
+ >
187
+ <form onSubmit={handleSubmit} className="product-reviews__form">
188
+ <h3 className="product-reviews__modal-title">Write a review</h3>
189
+ <div className="product-reviews__rating-input">
190
+ {Array.from({ length: 5 }).map((_, i) => {
191
+ const value = i + 1
192
+ const active = value <= (hoveredRating || rating)
193
+ return (
194
+ <button
195
+ key={value}
196
+ type="button"
197
+ className={`product-reviews__rating-btn${active ? " product-reviews__rating-btn--active" : ""}`}
198
+ onMouseEnter={() => setHoveredRating(value)}
199
+ onMouseLeave={() => setHoveredRating(0)}
200
+ onClick={() => setRating(value)}
201
+ aria-label={`${value} star${value > 1 ? "s" : ""}`}
202
+ >
203
+
204
+ </button>
205
+ )
206
+ })}
207
+ </div>
208
+ <label className="product-reviews__field">
209
+ <span>Title</span>
210
+ <input value={reviewTitle} onChange={(e) => setReviewTitle(e.target.value)} required />
211
+ </label>
212
+ <label className="product-reviews__field">
213
+ <span>Review</span>
214
+ <textarea value={reviewBody} onChange={(e) => setReviewBody(e.target.value)} rows={4} required />
215
+ </label>
216
+ {submitMessage ? (
217
+ <p className={`product-reviews__status product-reviews__status--${submitStatus}`}>{submitMessage}</p>
218
+ ) : null}
219
+ <div className="product-reviews__modal-actions">
220
+ <button type="button" className="btn-outline" onClick={() => setIsModalOpen(false)}>
221
+ Cancel
222
+ </button>
223
+ <button type="submit" className="btn-primary" disabled={submitStatus === "loading" || rating < 1}>
224
+ {submitStatus === "loading" ? "Submitting…" : "Submit review"}
225
+ </button>
226
+ </div>
227
+ </form>
228
+ </div>
229
+ </div>
230
+ ) : null}
231
+ </section>
232
+ )
233
+ }