Reviews & Layout
Scaled back google reviews to just get the star rating and number of reviews as google limits the number of reviews to 5. Also removed the reviews tab from homepage. And updated Appointments button layout
This commit is contained in:
18 files changed
+297
-615
No files matched your search
@@ -1,91 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { RefreshCw, Star, StarOff } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { setReviewHiddenAction, syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||
|
||||
export interface OwnerReviewRow {
|
||||
id: string;
|
||||
authorName: string;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
isHidden: boolean;
|
||||
}
|
||||
import { syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||
|
||||
export interface ReviewsManagerProps {
|
||||
initialGooglePlaceId: string;
|
||||
initialReviewsMinRating: number | null;
|
||||
initialReviewsExcludeRatingOnly: boolean;
|
||||
googleRating: number | null;
|
||||
googleRatingCount: number | null;
|
||||
reviews: OwnerReviewRow[];
|
||||
}
|
||||
|
||||
const RATING_FILTERS = [null, 5, 4, 3, 2, 1] as const;
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn("h-3.5 w-3.5", i < rating ? "fill-accent-loyalty text-accent-loyalty" : "text-line")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewsManager({
|
||||
initialGooglePlaceId,
|
||||
initialReviewsMinRating,
|
||||
initialReviewsExcludeRatingOnly,
|
||||
googleRating,
|
||||
googleRatingCount,
|
||||
reviews,
|
||||
}: ReviewsManagerProps) {
|
||||
/**
|
||||
* Just the Google Place ID + aggregate rating/count sync — no individual
|
||||
* review import/management. Google's Place Details API only ever returns
|
||||
* up to 5 "most relevant" reviews with no pagination to get the rest (see
|
||||
* google-places.provider.ts's header), so a review list here could never
|
||||
* be a real, representative picture — the aggregate rating/count alone is
|
||||
* accurate and is all this app surfaces.
|
||||
*/
|
||||
export function ReviewsManager({ initialGooglePlaceId, googleRating, googleRatingCount }: ReviewsManagerProps) {
|
||||
const router = useRouter();
|
||||
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
||||
const [reviewsMinRating, setReviewsMinRating] = useState(initialReviewsMinRating);
|
||||
const [reviewsExcludeRatingOnly, setReviewsExcludeRatingOnly] = useState(initialReviewsExcludeRatingOnly);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [isSettingsPending, startSettingsTransition] = useTransition();
|
||||
const [isSyncPending, startSyncTransition] = useTransition();
|
||||
|
||||
const [ratingFilter, setRatingFilter] = useState<number | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState(reviews);
|
||||
const [hidingId, setHidingId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (ratingFilter && r.rating !== ratingFilter) return false;
|
||||
if (q && !(r.authorName.toLowerCase().includes(q) || (r.text ?? "").toLowerCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rows, ratingFilter, query]);
|
||||
|
||||
const saveSettings = () => {
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
startSettingsTransition(async () => {
|
||||
const result = await updateReviewsSettingsAction({
|
||||
googlePlaceId: googlePlaceId.trim() || null,
|
||||
reviewsMinRating,
|
||||
reviewsExcludeRatingOnly,
|
||||
});
|
||||
const result = await updateReviewsSettingsAction({ googlePlaceId: googlePlaceId.trim() || null });
|
||||
if (!result.success) {
|
||||
setSettingsError(result.error ?? "Couldn't save review settings.");
|
||||
return;
|
||||
@@ -104,144 +53,41 @@ export function ReviewsManager({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleHidden = (review: OwnerReviewRow) => {
|
||||
setHidingId(review.id);
|
||||
const nextHidden = !review.isHidden;
|
||||
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: nextHidden } : r)));
|
||||
startSettingsTransition(async () => {
|
||||
const result = await setReviewHiddenAction(review.id, nextHidden);
|
||||
if (!result.success) {
|
||||
// Revert on failure.
|
||||
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: review.isHidden } : r)));
|
||||
}
|
||||
setHidingId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||
placeholder="ChIJ…"
|
||||
hint="Used to import this business's reviews from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable review syncing."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Minimum rating shown to customers"
|
||||
value={reviewsMinRating?.toString() ?? ""}
|
||||
onChange={(e) => setReviewsMinRating(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">No minimum</option>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}+ stars
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="border-line flex items-center rounded-xl border p-4">
|
||||
<Switch
|
||||
label="Exclude rating-only reviews"
|
||||
checked={reviewsExcludeRatingOnly}
|
||||
onCheckedChange={setReviewsExcludeRatingOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{settingsError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{settingsError}
|
||||
</p>
|
||||
)}
|
||||
{settingsSaved && !settingsError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Imported Reviews</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{RATING_FILTERS.map((r) => (
|
||||
<button
|
||||
key={r ?? "all"}
|
||||
type="button"
|
||||
onClick={() => setRatingFilter(r)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
ratingFilter === r ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{r ? `${r}★` : "All"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by author or review text"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white px-4 py-2.5 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No reviews match this filter.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((review) => (
|
||||
<Card key={review.id} className={cn("space-y-2 p-5", review.isHidden && "opacity-60")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StarRating rating={review.rating} />
|
||||
{review.relativeTimeDescription && (
|
||||
<span className="text-muted text-xs">{review.relativeTimeDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isLoading={hidingId === review.id}
|
||||
onClick={() => toggleHidden(review)}
|
||||
>
|
||||
{review.isHidden ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5" aria-hidden="true" /> Unhide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StarOff className="h-3.5 w-3.5" aria-hidden="true" /> Hide
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{review.text && <p className="text-muted text-sm">{review.text}</p>}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||
placeholder="ChIJ…"
|
||||
hint="Used to pull this business's rating and review count from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable syncing."
|
||||
/>
|
||||
{settingsError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{settingsError}
|
||||
</p>
|
||||
)}
|
||||
{settingsSaved && !settingsError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user