@reviewlico/cli 0.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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.js +414 -0
- package/dist/index.js.map +1 -0
- package/dist/registry/registry/plain/ReviewCard.tsx +36 -0
- package/dist/registry/registry/plain/ReviewForm.tsx +133 -0
- package/dist/registry/registry/plain/ReviewList.tsx +105 -0
- package/dist/registry/registry/plain/StarRating.tsx +62 -0
- package/dist/registry/registry/plain/review-components.css +492 -0
- package/dist/registry/registry/tailwind/ReviewCard.tsx +36 -0
- package/dist/registry/registry/tailwind/ReviewForm.tsx +199 -0
- package/dist/registry/registry/tailwind/ReviewList.tsx +108 -0
- package/dist/registry/registry/tailwind/StarRating.tsx +61 -0
- package/dist/registry/shared/api.ts +130 -0
- package/dist/registry/shared/hooks/useReviews.ts +65 -0
- package/dist/registry/shared/hooks/useSubmitReview.ts +36 -0
- package/dist/registry/shared/types.ts +56 -0
- package/package.json +55 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { ReviewFormProps } from '../../shared/types';
|
|
5
|
+
import { useSubmitReview } from '../../shared/hooks/useSubmitReview';
|
|
6
|
+
import { StarRating } from './StarRating';
|
|
7
|
+
|
|
8
|
+
export function ReviewForm({
|
|
9
|
+
config,
|
|
10
|
+
onSuccess,
|
|
11
|
+
onError,
|
|
12
|
+
title = 'Write a Review',
|
|
13
|
+
submitLabel = 'Submit Review',
|
|
14
|
+
className,
|
|
15
|
+
}: ReviewFormProps) {
|
|
16
|
+
const { state, submit } = useSubmitReview(config);
|
|
17
|
+
|
|
18
|
+
const [rating, setRating] = useState(0);
|
|
19
|
+
const [text, setText] = useState('');
|
|
20
|
+
const [reviewerName, setReviewerName] = useState('');
|
|
21
|
+
const [reviewerEmail, setReviewerEmail] = useState('');
|
|
22
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (state.status === 'success') {
|
|
26
|
+
const t = setTimeout(() => onSuccess?.(state.review), 2000);
|
|
27
|
+
return () => clearTimeout(t);
|
|
28
|
+
}
|
|
29
|
+
if (state.status === 'error') onError?.(state.message);
|
|
30
|
+
}, [state, onSuccess, onError]);
|
|
31
|
+
|
|
32
|
+
function validate() {
|
|
33
|
+
const next: Record<string, string> = {};
|
|
34
|
+
if (rating === 0) next.rating = 'Please select a star rating.';
|
|
35
|
+
if (!text.trim()) next.text = 'Review text is required.';
|
|
36
|
+
if (text.trim().length > 5000) next.text = 'Review must be 5000 characters or less.';
|
|
37
|
+
if (!reviewerName.trim()) next.reviewerName = 'Your name is required.';
|
|
38
|
+
if (!reviewerEmail.trim()) next.reviewerEmail = 'Your email is required.';
|
|
39
|
+
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail.trim())) {
|
|
40
|
+
next.reviewerEmail = 'Enter a valid email address.';
|
|
41
|
+
}
|
|
42
|
+
setErrors(next);
|
|
43
|
+
return Object.keys(next).length === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleSubmit(e: React.FormEvent) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
if (!validate()) return;
|
|
49
|
+
void submit({
|
|
50
|
+
rating,
|
|
51
|
+
text: text.trim(),
|
|
52
|
+
reviewerName: reviewerName.trim(),
|
|
53
|
+
reviewerEmail: reviewerEmail.trim(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rootClass = ['rc-root', 'rc-form', className].filter(Boolean).join(' ');
|
|
58
|
+
|
|
59
|
+
if (state.status === 'success') {
|
|
60
|
+
return (
|
|
61
|
+
<div className={rootClass}>
|
|
62
|
+
<div className="rc-form__success">
|
|
63
|
+
<span className="rc-form__success-icon">✓</span>
|
|
64
|
+
<p className="rc-form__success-heading">Thank you!</p>
|
|
65
|
+
<p className="rc-form__success-message">Your review has been submitted.</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<form className={rootClass} onSubmit={handleSubmit} noValidate>
|
|
73
|
+
<h3 className="rc-form__title">{title}</h3>
|
|
74
|
+
|
|
75
|
+
<div className="rc-field">
|
|
76
|
+
<span className="rc-label">Rating</span>
|
|
77
|
+
<StarRating value={rating} onChange={setRating} />
|
|
78
|
+
{errors.rating ? <span className="rc-field__error">{errors.rating}</span> : null}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="rc-field">
|
|
82
|
+
<label className="rc-label" htmlFor="rc-text">Review</label>
|
|
83
|
+
<textarea
|
|
84
|
+
id="rc-text"
|
|
85
|
+
className="rc-textarea"
|
|
86
|
+
value={text}
|
|
87
|
+
onChange={(e) => setText(e.target.value)}
|
|
88
|
+
placeholder="Share your experience…"
|
|
89
|
+
rows={4}
|
|
90
|
+
/>
|
|
91
|
+
{errors.text ? <span className="rc-field__error">{errors.text}</span> : null}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="rc-field">
|
|
95
|
+
<label className="rc-label" htmlFor="rc-name">Your name</label>
|
|
96
|
+
<input
|
|
97
|
+
id="rc-name"
|
|
98
|
+
className="rc-input"
|
|
99
|
+
type="text"
|
|
100
|
+
value={reviewerName}
|
|
101
|
+
onChange={(e) => setReviewerName(e.target.value)}
|
|
102
|
+
placeholder="Jane Smith"
|
|
103
|
+
/>
|
|
104
|
+
{errors.reviewerName ? <span className="rc-field__error">{errors.reviewerName}</span> : null}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="rc-field">
|
|
108
|
+
<label className="rc-label" htmlFor="rc-email">Email address</label>
|
|
109
|
+
<input
|
|
110
|
+
id="rc-email"
|
|
111
|
+
className="rc-input"
|
|
112
|
+
type="email"
|
|
113
|
+
value={reviewerEmail}
|
|
114
|
+
onChange={(e) => setReviewerEmail(e.target.value)}
|
|
115
|
+
placeholder="jane@example.com"
|
|
116
|
+
/>
|
|
117
|
+
{errors.reviewerEmail ? <span className="rc-field__error">{errors.reviewerEmail}</span> : null}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{state.status === 'error' ? (
|
|
121
|
+
<p key={state.message} className="rc-error-message">{state.message}</p>
|
|
122
|
+
) : null}
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
type="submit"
|
|
126
|
+
className={['rc-button', state.status === 'submitting' ? 'rc-button--loading' : ''].filter(Boolean).join(' ')}
|
|
127
|
+
disabled={state.status === 'submitting'}
|
|
128
|
+
>
|
|
129
|
+
{state.status === 'submitting' ? 'Submitting…' : submitLabel}
|
|
130
|
+
</button>
|
|
131
|
+
</form>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ReviewListProps } from '../../shared/types';
|
|
5
|
+
import { useReviews } from '../../shared/hooks/useReviews';
|
|
6
|
+
import { ReviewCard } from './ReviewCard';
|
|
7
|
+
import { ReviewForm } from './ReviewForm';
|
|
8
|
+
|
|
9
|
+
function ReviewListInner({
|
|
10
|
+
config,
|
|
11
|
+
limit = 10,
|
|
12
|
+
title = 'Customer Reviews',
|
|
13
|
+
showForm = false,
|
|
14
|
+
className,
|
|
15
|
+
}: ReviewListProps) {
|
|
16
|
+
const [page, setPage] = useState(1);
|
|
17
|
+
const [formVisible, setFormVisible] = useState(false);
|
|
18
|
+
const { state, reload } = useReviews(config, page, limit);
|
|
19
|
+
|
|
20
|
+
const rootClass = ['rc-root', 'rc-list', className].filter(Boolean).join(' ');
|
|
21
|
+
|
|
22
|
+
const pagination = state.status === 'success' ? state.data.pagination : null;
|
|
23
|
+
|
|
24
|
+
function handleFormSuccess() {
|
|
25
|
+
setFormVisible(false);
|
|
26
|
+
if (page === 1) {
|
|
27
|
+
reload();
|
|
28
|
+
} else {
|
|
29
|
+
setPage(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={rootClass}>
|
|
35
|
+
<div className="rc-list__header">
|
|
36
|
+
<h3 className="rc-list__title">{title}</h3>
|
|
37
|
+
{showForm && !formVisible ? (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
className="rc-list__toggle-form"
|
|
41
|
+
onClick={() => setFormVisible(true)}
|
|
42
|
+
>
|
|
43
|
+
Write a review
|
|
44
|
+
</button>
|
|
45
|
+
) : null}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{formVisible ? (
|
|
49
|
+
<ReviewForm config={config} onSuccess={handleFormSuccess} />
|
|
50
|
+
) : null}
|
|
51
|
+
|
|
52
|
+
{state.status === 'loading' ? (
|
|
53
|
+
<div className="rc-cards">
|
|
54
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
55
|
+
<div key={i} className="rc-skeleton rc-skeleton--card" />
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
) : state.status === 'error' ? (
|
|
59
|
+
<p className="rc-list__error">{state.message}</p>
|
|
60
|
+
) : state.status === 'success' && state.data.reviews.length === 0 ? (
|
|
61
|
+
<p className="rc-list__empty">No reviews yet. Be the first!</p>
|
|
62
|
+
) : state.status === 'success' ? (
|
|
63
|
+
<div className="rc-cards">
|
|
64
|
+
{state.data.reviews.map((review) => (
|
|
65
|
+
<ReviewCard key={review._id} review={review} />
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
) : null}
|
|
69
|
+
|
|
70
|
+
{pagination && pagination.totalPages > 1 ? (
|
|
71
|
+
<div className="rc-pagination">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className="rc-pagination__btn"
|
|
75
|
+
onClick={() => setPage((p) => p - 1)}
|
|
76
|
+
disabled={page <= 1 || state.status === 'loading'}
|
|
77
|
+
>
|
|
78
|
+
← Previous
|
|
79
|
+
</button>
|
|
80
|
+
<span className="rc-pagination__info">
|
|
81
|
+
Page {page} of {pagination.totalPages} · {pagination.total} reviews
|
|
82
|
+
</span>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
className="rc-pagination__btn"
|
|
86
|
+
onClick={() => setPage((p) => p + 1)}
|
|
87
|
+
disabled={page >= pagination.totalPages || state.status === 'loading'}
|
|
88
|
+
>
|
|
89
|
+
Next →
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ReviewList(props: ReviewListProps) {
|
|
98
|
+
const { config } = props;
|
|
99
|
+
const key = [
|
|
100
|
+
config.apiUrl ?? '',
|
|
101
|
+
config.apiKey ?? '',
|
|
102
|
+
config.externalProductId ?? '',
|
|
103
|
+
].join('|');
|
|
104
|
+
return <ReviewListInner key={key} {...props} />;
|
|
105
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface StarRatingProps {
|
|
6
|
+
value: number;
|
|
7
|
+
onChange?: (value: number) => void;
|
|
8
|
+
size?: 'sm' | 'md';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function StarRating({ value, onChange, size = 'md' }: StarRatingProps) {
|
|
12
|
+
const [hovered, setHovered] = useState(0);
|
|
13
|
+
const [justSelected, setJustSelected] = useState(0);
|
|
14
|
+
const interactive = typeof onChange !== 'undefined';
|
|
15
|
+
const display = hovered > 0 ? hovered : value;
|
|
16
|
+
|
|
17
|
+
const starsClass = ['rc-stars rc-root', size === 'sm' ? 'rc-stars--sm' : ''].filter(Boolean).join(' ');
|
|
18
|
+
|
|
19
|
+
function handleClick(star: number) {
|
|
20
|
+
onChange!(star);
|
|
21
|
+
setJustSelected(star);
|
|
22
|
+
setTimeout(() => setJustSelected(0), 400);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<span className={starsClass}>
|
|
27
|
+
{[1, 2, 3, 4, 5].map((star) => {
|
|
28
|
+
const filled = star <= display;
|
|
29
|
+
const starClass = [
|
|
30
|
+
'rc-star',
|
|
31
|
+
filled ? 'rc-star--filled' : '',
|
|
32
|
+
interactive ? 'rc-star--interactive' : '',
|
|
33
|
+
interactive && justSelected === star ? 'rc-star--just-selected' : '',
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(' ');
|
|
37
|
+
|
|
38
|
+
if (interactive) {
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
key={star}
|
|
42
|
+
type="button"
|
|
43
|
+
className={starClass}
|
|
44
|
+
aria-label={`Rate ${star} star${star !== 1 ? 's' : ''}`}
|
|
45
|
+
onClick={() => handleClick(star)}
|
|
46
|
+
onMouseEnter={() => setHovered(star)}
|
|
47
|
+
onMouseLeave={() => setHovered(0)}
|
|
48
|
+
>
|
|
49
|
+
★
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<span key={star} className={starClass}>
|
|
56
|
+
{filled ? '★' : '☆'}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|