@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.
@@ -0,0 +1,199 @@
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
+ const KEYFRAMES = `
9
+ @keyframes rcErrorIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}
10
+ @keyframes rcShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-6px)}30%{transform:translateX(5px)}45%{transform:translateX(-4px)}60%{transform:translateX(3px)}75%{transform:translateX(-2px)}}
11
+ @keyframes rcSuccessIn{from{opacity:0;transform:scale(0.92)}to{opacity:1;transform:scale(1)}}
12
+ @keyframes rcCheckBounce{0%{transform:scale(0);opacity:0}60%{transform:scale(1.3);opacity:1}100%{transform:scale(1);opacity:1}}
13
+ @keyframes rcSpin{to{transform:rotate(360deg)}}
14
+ @media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
15
+ `;
16
+
17
+ export function ReviewForm({
18
+ config,
19
+ onSuccess,
20
+ onError,
21
+ title = 'Write a Review',
22
+ submitLabel = 'Submit Review',
23
+ className,
24
+ }: ReviewFormProps) {
25
+ const { state, submit } = useSubmitReview(config);
26
+
27
+ const [rating, setRating] = useState(0);
28
+ const [text, setText] = useState('');
29
+ const [reviewerName, setReviewerName] = useState('');
30
+ const [reviewerEmail, setReviewerEmail] = useState('');
31
+ const [errors, setErrors] = useState<Record<string, string>>({});
32
+
33
+ useEffect(() => {
34
+ if (state.status === 'success') {
35
+ const t = setTimeout(() => onSuccess?.(state.review), 2000);
36
+ return () => clearTimeout(t);
37
+ }
38
+ if (state.status === 'error') onError?.(state.message);
39
+ }, [state, onSuccess, onError]);
40
+
41
+ function validate() {
42
+ const next: Record<string, string> = {};
43
+ if (rating === 0) next.rating = 'Please select a star rating.';
44
+ if (!text.trim()) next.text = 'Review text is required.';
45
+ if (text.trim().length > 5000) next.text = 'Review must be 5000 characters or less.';
46
+ if (!reviewerName.trim()) next.reviewerName = 'Your name is required.';
47
+ if (!reviewerEmail.trim()) next.reviewerEmail = 'Your email is required.';
48
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail.trim())) {
49
+ next.reviewerEmail = 'Enter a valid email address.';
50
+ }
51
+ setErrors(next);
52
+ return Object.keys(next).length === 0;
53
+ }
54
+
55
+ function handleSubmit(e: React.FormEvent) {
56
+ e.preventDefault();
57
+ if (!validate()) return;
58
+ void submit({
59
+ rating,
60
+ text: text.trim(),
61
+ reviewerName: reviewerName.trim(),
62
+ reviewerEmail: reviewerEmail.trim(),
63
+ });
64
+ }
65
+
66
+ const inputClass =
67
+ 'h-10 w-full rounded-lg border border-[#1e2530] bg-[#0f1318] px-3 text-sm text-[#e8edf5] placeholder:text-[#8a98ab] focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 transition-colors';
68
+ const labelClass = 'text-sm font-medium text-[#e8edf5]';
69
+ const errorClass = 'text-xs text-red-300';
70
+
71
+ const rootClass = [
72
+ 'rounded-2xl border border-[#1e2530] p-5 flex flex-col gap-5 w-full max-w-[72rem] mx-auto',
73
+ 'bg-[linear-gradient(180deg,rgba(255,255,255,0.02),rgba(255,255,255,0)),#0f1318]',
74
+ className,
75
+ ]
76
+ .filter(Boolean)
77
+ .join(' ');
78
+
79
+ if (state.status === 'success') {
80
+ return (
81
+ <div className={rootClass}>
82
+ <style>{KEYFRAMES}</style>
83
+ <div
84
+ className="flex flex-col items-center gap-3 py-8 text-center"
85
+ style={{ animation: 'rcSuccessIn 0.4s ease-out both' }}
86
+ >
87
+ <span
88
+ className="text-3xl leading-none text-green-400"
89
+ style={{ animation: 'rcCheckBounce 0.5s cubic-bezier(0.34,1.56,0.64,1) 0.15s both', display: 'inline-block' }}
90
+ >
91
+
92
+ </span>
93
+ <p className="text-lg font-semibold tracking-tight text-green-300">Thank you!</p>
94
+ <p className="text-sm text-[#8a98ab]">Your review has been submitted.</p>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ return (
101
+ <form className={rootClass} onSubmit={handleSubmit} noValidate>
102
+ <style>{KEYFRAMES}</style>
103
+ <h3 className="text-lg font-semibold tracking-tight text-[#e8edf5]">{title}</h3>
104
+
105
+ <div className="flex flex-col gap-1.5">
106
+ <span className={labelClass}>Rating</span>
107
+ <StarRating value={rating} onChange={setRating} />
108
+ {errors.rating ? (
109
+ <span key={errors.rating} className={errorClass} style={{ animation: 'rcErrorIn 0.25s ease-out both' }}>
110
+ {errors.rating}
111
+ </span>
112
+ ) : null}
113
+ </div>
114
+
115
+ <div className="flex flex-col gap-1.5">
116
+ <label className={labelClass} htmlFor="rc-text">Review</label>
117
+ <textarea
118
+ id="rc-text"
119
+ className={`${inputClass} !h-auto min-h-[7rem] resize-y py-2`}
120
+ value={text}
121
+ onChange={(e) => setText(e.target.value)}
122
+ placeholder="Share your experience…"
123
+ rows={4}
124
+ />
125
+ {errors.text ? (
126
+ <span key={errors.text} className={errorClass} style={{ animation: 'rcErrorIn 0.25s ease-out both' }}>
127
+ {errors.text}
128
+ </span>
129
+ ) : null}
130
+ </div>
131
+
132
+ <div className="flex flex-col gap-1.5">
133
+ <label className={labelClass} htmlFor="rc-name">Your name</label>
134
+ <input
135
+ id="rc-name"
136
+ className={inputClass}
137
+ type="text"
138
+ value={reviewerName}
139
+ onChange={(e) => setReviewerName(e.target.value)}
140
+ placeholder="Jane Smith"
141
+ />
142
+ {errors.reviewerName ? (
143
+ <span key={errors.reviewerName} className={errorClass} style={{ animation: 'rcErrorIn 0.25s ease-out both' }}>
144
+ {errors.reviewerName}
145
+ </span>
146
+ ) : null}
147
+ </div>
148
+
149
+ <div className="flex flex-col gap-1.5">
150
+ <label className={labelClass} htmlFor="rc-email">Email address</label>
151
+ <input
152
+ id="rc-email"
153
+ className={inputClass}
154
+ type="email"
155
+ value={reviewerEmail}
156
+ onChange={(e) => setReviewerEmail(e.target.value)}
157
+ placeholder="jane@example.com"
158
+ />
159
+ {errors.reviewerEmail ? (
160
+ <span key={errors.reviewerEmail} className={errorClass} style={{ animation: 'rcErrorIn 0.25s ease-out both' }}>
161
+ {errors.reviewerEmail}
162
+ </span>
163
+ ) : null}
164
+ </div>
165
+
166
+ {state.status === 'error' ? (
167
+ <p
168
+ key={state.message}
169
+ className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300"
170
+ style={{ animation: 'rcShake 0.5s ease-in-out' }}
171
+ >
172
+ {state.message}
173
+ </p>
174
+ ) : null}
175
+
176
+ <button
177
+ type="submit"
178
+ className="self-start inline-flex h-9 items-center gap-2 rounded-lg border border-blue-500 bg-blue-500 px-3 text-sm font-medium text-white transition-colors hover:border-blue-600 hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 disabled:pointer-events-none disabled:opacity-50"
179
+ disabled={state.status === 'submitting'}
180
+ >
181
+ {state.status === 'submitting' ? (
182
+ <>
183
+ <svg
184
+ className="h-3.5 w-3.5 shrink-0"
185
+ viewBox="0 0 14 14"
186
+ fill="none"
187
+ style={{ animation: 'rcSpin 0.8s linear infinite' }}
188
+ aria-hidden="true"
189
+ >
190
+ <circle cx="7" cy="7" r="5.5" stroke="rgba(255,255,255,0.3)" strokeWidth="2" />
191
+ <path d="M7 1.5A5.5 5.5 0 0 1 12.5 7" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
192
+ </svg>
193
+ Submitting…
194
+ </>
195
+ ) : submitLabel}
196
+ </button>
197
+ </form>
198
+ );
199
+ }
@@ -0,0 +1,108 @@
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 pagination = state.status === 'success' ? state.data.pagination : null;
21
+
22
+ function handleFormSuccess() {
23
+ setFormVisible(false);
24
+ if (page === 1) {
25
+ reload();
26
+ } else {
27
+ setPage(1);
28
+ }
29
+ }
30
+
31
+ const rootClass = ['flex flex-col gap-5 p-4 sm:p-6 mx-auto max-w-6xl', className].filter(Boolean).join(' ');
32
+
33
+ return (
34
+ <div className={rootClass}>
35
+ <div className="flex items-center justify-between gap-4">
36
+ <h3 className="text-xl font-semibold tracking-tight text-[#e8edf5]">{title}</h3>
37
+ {showForm && !formVisible ? (
38
+ <button
39
+ type="button"
40
+ className="inline-flex h-9 items-center rounded-lg border border-[#1e2530] bg-transparent px-3 text-sm text-[#8a98ab] transition-colors hover:bg-white/5 hover:text-[#e8edf5]"
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="flex flex-col gap-3">
54
+ {Array.from({ length: 3 }).map((_, i) => (
55
+ <div
56
+ key={i}
57
+ className="h-24 animate-pulse rounded-xl border border-white/10 bg-white/5"
58
+ />
59
+ ))}
60
+ </div>
61
+ ) : state.status === 'error' ? (
62
+ <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-300">{state.message}</p>
63
+ ) : state.status === 'success' && state.data.reviews.length === 0 ? (
64
+ <p className="py-8 text-center text-sm text-[#8a98ab]">No reviews yet. Be the first!</p>
65
+ ) : state.status === 'success' ? (
66
+ <div className="flex flex-col gap-3">
67
+ {state.data.reviews.map((review) => (
68
+ <ReviewCard key={review._id} review={review} />
69
+ ))}
70
+ </div>
71
+ ) : null}
72
+
73
+ {pagination && pagination.totalPages > 1 ? (
74
+ <div className="flex items-center justify-between gap-4 pt-2">
75
+ <button
76
+ type="button"
77
+ className="inline-flex h-9 items-center rounded-lg border border-[#1e2530] bg-transparent px-3 text-sm text-[#8a98ab] transition-colors hover:bg-white/5 hover:text-[#e8edf5] disabled:pointer-events-none disabled:opacity-40"
78
+ onClick={() => setPage((p) => p - 1)}
79
+ disabled={page <= 1 || state.status === 'loading'}
80
+ >
81
+ ← Previous
82
+ </button>
83
+ <span className="text-xs text-[#8a98ab]">
84
+ Page {page} of {pagination.totalPages} · {pagination.total} reviews
85
+ </span>
86
+ <button
87
+ type="button"
88
+ className="inline-flex h-9 items-center rounded-lg border border-[#1e2530] bg-transparent px-3 text-sm text-[#8a98ab] transition-colors hover:bg-white/5 hover:text-[#e8edf5] disabled:pointer-events-none disabled:opacity-40"
89
+ onClick={() => setPage((p) => p + 1)}
90
+ disabled={page >= pagination.totalPages || state.status === 'loading'}
91
+ >
92
+ Next →
93
+ </button>
94
+ </div>
95
+ ) : null}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export function ReviewList(props: ReviewListProps) {
101
+ const { config } = props;
102
+ const key = [
103
+ config.apiUrl ?? '',
104
+ config.apiKey ?? '',
105
+ config.externalProductId ?? '',
106
+ ].join('|');
107
+ return <ReviewListInner key={key} {...props} />;
108
+ }
@@ -0,0 +1,61 @@
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
+ const STAR_POP_KEYFRAME = `@keyframes rcStarPop{0%{transform:scale(1)}40%{transform:scale(1.4)}100%{transform:scale(1)}}`;
12
+
13
+ export function StarRating({ value, onChange, size = 'md' }: StarRatingProps) {
14
+ const [hovered, setHovered] = useState(0);
15
+ const [justSelected, setJustSelected] = useState(0);
16
+ const interactive = typeof onChange !== 'undefined';
17
+ const display = hovered > 0 ? hovered : value;
18
+
19
+ const starSize = size === 'sm' ? 'text-base' : 'text-xl';
20
+
21
+ function handleClick(star: number) {
22
+ onChange!(star);
23
+ setJustSelected(star);
24
+ setTimeout(() => setJustSelected(0), 400);
25
+ }
26
+
27
+ return (
28
+ <>
29
+ {interactive ? <style>{STAR_POP_KEYFRAME}</style> : null}
30
+ <span className="inline-flex items-center gap-0.5">
31
+ {[1, 2, 3, 4, 5].map((star) => {
32
+ const filled = star <= display;
33
+ const colorClass = filled ? 'text-yellow-400' : 'text-gray-600';
34
+
35
+ if (interactive) {
36
+ return (
37
+ <button
38
+ key={star}
39
+ type="button"
40
+ className={`${starSize} ${colorClass} cursor-pointer border-none bg-transparent p-0.5 leading-none transition-[color,filter,transform] duration-150 hover:scale-110 hover:drop-shadow-[0_0_5px_rgba(250,204,21,0.55)]`}
41
+ style={justSelected === star ? { animation: 'rcStarPop 0.4s cubic-bezier(0.34,1.56,0.64,1)' } : undefined}
42
+ aria-label={`Rate ${star} star${star !== 1 ? 's' : ''}`}
43
+ onClick={() => handleClick(star)}
44
+ onMouseEnter={() => setHovered(star)}
45
+ onMouseLeave={() => setHovered(0)}
46
+ >
47
+
48
+ </button>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <span key={star} className={`${starSize} ${colorClass} leading-none`}>
54
+ {filled ? '★' : '☆'}
55
+ </span>
56
+ );
57
+ })}
58
+ </span>
59
+ </>
60
+ );
61
+ }
@@ -0,0 +1,130 @@
1
+ import type { PublicReview, ReviewComponentConfig, ReviewPagination } from './types';
2
+
3
+ /**
4
+ * Resolves apiUrl and apiKey from props or env vars.
5
+ * Fallback chain: config prop → VITE_REVIEWLICO_* (Vite) → NEXT_PUBLIC_REVIEWLICO_* (Next.js) → REVIEWLICO_* (generic)
6
+ * Throws immediately if either value cannot be resolved — never attempts a fetch with missing config.
7
+ */
8
+ function resolveConfig(config: ReviewComponentConfig): { apiUrl: string; apiKey: string } {
9
+ // import.meta.env is Vite-only, inlined at build time.
10
+ // The typeof guard prevents crashes in non-Vite builds (Next.js, Node).
11
+ // Cast through unknown — ImportMeta is a closed type in strict TS so direct casts fail.
12
+ const meta = import.meta as unknown as Record<string, unknown>;
13
+ const viteEnv =
14
+ typeof meta.env === 'object' && meta.env !== null
15
+ ? (meta.env as Record<string, string | undefined>)
16
+ : ({} as Record<string, string | undefined>);
17
+
18
+ // Read NEXT_PUBLIC_* directly so Next.js can inline values at build time.
19
+ // Keep a typeof guard so this stays safe in non-Node runtimes.
20
+ const nextPublicApiUrl =
21
+ typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_REVIEWLICO_API_URL : undefined;
22
+ const nextPublicApiKey =
23
+ typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_REVIEWLICO_API_KEY : undefined;
24
+ const genericApiUrl =
25
+ typeof process !== 'undefined' ? process.env.REVIEWLICO_API_URL : undefined;
26
+ const genericApiKey =
27
+ typeof process !== 'undefined' ? process.env.REVIEWLICO_API_KEY : undefined;
28
+
29
+ const apiUrl =
30
+ config.apiUrl ??
31
+ viteEnv.VITE_REVIEWLICO_API_URL ??
32
+ nextPublicApiUrl ??
33
+ genericApiUrl;
34
+
35
+ const apiKey =
36
+ config.apiKey ??
37
+ viteEnv.VITE_REVIEWLICO_API_KEY ??
38
+ nextPublicApiKey ??
39
+ genericApiKey;
40
+
41
+ if (!apiUrl)
42
+ throw new Error(
43
+ '[reviewlico] apiUrl is required. Pass it as a prop or set one of: ' +
44
+ 'VITE_REVIEWLICO_API_URL, NEXT_PUBLIC_REVIEWLICO_API_URL, REVIEWLICO_API_URL',
45
+ );
46
+ if (!apiKey)
47
+ throw new Error(
48
+ '[reviewlico] apiKey is required. Pass it as a prop or set one of: ' +
49
+ 'VITE_REVIEWLICO_API_KEY, NEXT_PUBLIC_REVIEWLICO_API_KEY, REVIEWLICO_API_KEY',
50
+ );
51
+
52
+ return { apiUrl, apiKey };
53
+ }
54
+
55
+ async function apiFetch<T>(
56
+ config: ReviewComponentConfig,
57
+ path: string,
58
+ init?: RequestInit,
59
+ ): Promise<T> {
60
+ const { apiUrl, apiKey } = resolveConfig(config);
61
+ const url = `${apiUrl.replace(/\/$/, '')}${path}`;
62
+ const response = await fetch(url, {
63
+ ...init,
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ 'X-API-Key': apiKey,
67
+ ...init?.headers,
68
+ },
69
+ });
70
+
71
+ if (!response.ok) {
72
+ let message = `Request failed with status ${response.status}`;
73
+ try {
74
+ const body = (await response.json()) as { message?: string };
75
+ if (body.message) message = body.message;
76
+ } catch {
77
+ // ignore JSON parse errors on error bodies
78
+ }
79
+ throw new Error(message);
80
+ }
81
+
82
+ return response.json() as Promise<T>;
83
+ }
84
+
85
+ export interface FetchReviewsParams {
86
+ externalProductId?: string;
87
+ page?: number;
88
+ limit?: number;
89
+ }
90
+
91
+ export interface FetchReviewsResult {
92
+ reviews: PublicReview[];
93
+ pagination: ReviewPagination;
94
+ }
95
+
96
+ export async function fetchReviews(
97
+ config: ReviewComponentConfig,
98
+ params: FetchReviewsParams,
99
+ ): Promise<FetchReviewsResult> {
100
+ const qs = new URLSearchParams();
101
+ if (params.externalProductId) {
102
+ qs.set('scope', 'product');
103
+ qs.set('externalProductId', params.externalProductId);
104
+ } else {
105
+ qs.set('scope', 'org');
106
+ }
107
+ if (params.page) qs.set('page', String(params.page));
108
+ if (params.limit) qs.set('limit', String(params.limit));
109
+
110
+ return apiFetch<FetchReviewsResult>(config, `/public/reviews?${qs.toString()}`);
111
+ }
112
+
113
+ export interface CreateReviewPayload {
114
+ rating: number;
115
+ text: string;
116
+ reviewerName: string;
117
+ reviewerEmail: string;
118
+ externalProductId?: string;
119
+ }
120
+
121
+ export async function createReview(
122
+ config: ReviewComponentConfig,
123
+ payload: CreateReviewPayload,
124
+ ): Promise<PublicReview> {
125
+ const result = await apiFetch<{ review: PublicReview }>(config, '/public/reviews', {
126
+ method: 'POST',
127
+ body: JSON.stringify(payload),
128
+ });
129
+ return result.review;
130
+ }
@@ -0,0 +1,65 @@
1
+ import { useCallback, useEffect, useReducer } from 'react';
2
+ import { fetchReviews, type FetchReviewsResult } from '../api';
3
+ import type { ReviewComponentConfig } from '../types';
4
+
5
+ type State =
6
+ | { status: 'idle' }
7
+ | { status: 'loading' }
8
+ | { status: 'success'; data: FetchReviewsResult }
9
+ | { status: 'error'; message: string };
10
+
11
+ type Action =
12
+ | { type: 'FETCH_START' }
13
+ | { type: 'FETCH_SUCCESS'; payload: FetchReviewsResult }
14
+ | { type: 'FETCH_ERROR'; message: string };
15
+
16
+ function reducer(state: State, action: Action): State {
17
+ switch (action.type) {
18
+ case 'FETCH_START':
19
+ return { status: 'loading' };
20
+ case 'FETCH_SUCCESS':
21
+ return { status: 'success', data: action.payload };
22
+ case 'FETCH_ERROR':
23
+ return { status: 'error', message: action.message };
24
+ default:
25
+ return state;
26
+ }
27
+ }
28
+
29
+ export function useReviews(config: ReviewComponentConfig, page: number, limit: number) {
30
+ const [state, dispatch] = useReducer(reducer, { status: 'idle' });
31
+ const { apiUrl, apiKey, externalProductId } = config;
32
+
33
+ const load = useCallback(() => {
34
+ let cancelled = false;
35
+
36
+ dispatch({ type: 'FETCH_START' });
37
+
38
+ fetchReviews({ apiUrl, apiKey, externalProductId }, {
39
+ externalProductId,
40
+ page,
41
+ limit,
42
+ })
43
+ .then((data) => {
44
+ if (!cancelled) dispatch({ type: 'FETCH_SUCCESS', payload: data });
45
+ })
46
+ .catch((err: unknown) => {
47
+ if (!cancelled) {
48
+ dispatch({
49
+ type: 'FETCH_ERROR',
50
+ message: err instanceof Error ? err.message : 'Failed to load reviews',
51
+ });
52
+ }
53
+ });
54
+
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ }, [apiUrl, apiKey, externalProductId, page, limit]);
59
+
60
+ useEffect(() => {
61
+ return load();
62
+ }, [load]);
63
+
64
+ return { state, reload: load };
65
+ }
@@ -0,0 +1,36 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { createReview, type CreateReviewPayload } from '../api';
3
+ import type { PublicReview, ReviewComponentConfig } from '../types';
4
+
5
+ type SubmitState =
6
+ | { status: 'idle' }
7
+ | { status: 'submitting' }
8
+ | { status: 'success'; review: PublicReview }
9
+ | { status: 'error'; message: string };
10
+
11
+ export function useSubmitReview(config: ReviewComponentConfig) {
12
+ const [state, setState] = useState<SubmitState>({ status: 'idle' });
13
+
14
+ const submit = useCallback(
15
+ async (payload: Omit<CreateReviewPayload, 'externalProductId'>) => {
16
+ setState({ status: 'submitting' });
17
+ try {
18
+ const review = await createReview(config, {
19
+ ...payload,
20
+ externalProductId: config.externalProductId,
21
+ });
22
+ setState({ status: 'success', review });
23
+ } catch (err) {
24
+ setState({
25
+ status: 'error',
26
+ message: err instanceof Error ? err.message : 'Submission failed',
27
+ });
28
+ }
29
+ },
30
+ [config],
31
+ );
32
+
33
+ const reset = useCallback(() => setState({ status: 'idle' }), []);
34
+
35
+ return { state, submit, reset };
36
+ }
@@ -0,0 +1,56 @@
1
+ export interface PublicReview {
2
+ _id: string;
3
+ externalProductId?: string;
4
+ rating: number;
5
+ text: string;
6
+ reviewerName: string;
7
+ createdAt?: string;
8
+ }
9
+
10
+ export interface ReviewPagination {
11
+ total: number;
12
+ page: number;
13
+ limit: number;
14
+ totalPages: number;
15
+ }
16
+
17
+ export interface ReviewComponentConfig {
18
+ /**
19
+ * Base URL of the reviewlico API, e.g. "https://api.example.com".
20
+ * If omitted, resolved from env vars in order:
21
+ * VITE_REVIEWLICO_API_URL → NEXT_PUBLIC_REVIEWLICO_API_URL → REVIEWLICO_API_URL
22
+ * Note: only Vite and Next.js are supported. CRA is not supported.
23
+ */
24
+ apiUrl?: string;
25
+ /**
26
+ * Raw API key (rk_...) sent as the X-API-Key header.
27
+ * This key is client-side and will be visible in the browser bundle — treat it as a public key.
28
+ * If omitted, resolved from env vars in order:
29
+ * VITE_REVIEWLICO_API_KEY → NEXT_PUBLIC_REVIEWLICO_API_KEY → REVIEWLICO_API_KEY
30
+ */
31
+ apiKey?: string;
32
+ /** Scope reviews to a specific product */
33
+ externalProductId?: string;
34
+ }
35
+
36
+ export interface ReviewFormProps {
37
+ config: ReviewComponentConfig;
38
+ onSuccess?: (review: PublicReview) => void;
39
+ onError?: (message: string) => void;
40
+ /** Form heading. Default: "Write a Review" */
41
+ title?: string;
42
+ /** Submit button label. Default: "Submit Review" */
43
+ submitLabel?: string;
44
+ className?: string;
45
+ }
46
+
47
+ export interface ReviewListProps {
48
+ config: ReviewComponentConfig;
49
+ /** Reviews per page. Default: 10. Max: 100. */
50
+ limit?: number;
51
+ /** Section heading. Default: "Customer Reviews" */
52
+ title?: string;
53
+ /** Render an inline ReviewForm. Default: false */
54
+ showForm?: boolean;
55
+ className?: string;
56
+ }