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:
Stefan Lazic committed 2026-08-06 09:57:24 +01:00
1 parent 92f062a861
commit f07c30da0d
18 files changed
+297 -615

No files matched your search

+12 -34
View File
@@ -6,8 +6,7 @@ import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { updateBusinessProfile } from "@/services/business/business.service";
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
export interface ReviewsActionResult {
@@ -33,25 +32,18 @@ async function requireReviewsManager() {
}
/**
* Reuses updateBusinessProfile/updateBusinessSettings (the same functions
* Business Details uses) rather than a parallel update path —
* googlePlaceId lives on Business, the two policy flags on
* BusinessSettings. Deliberately no format regex on googlePlaceId beyond
* trimming — Google's own Place ID formats vary (place, postal code,
* plus-code IDs all look different) and this app has no need to validate
* more than "the owner typed something."
* Reuses updateBusinessProfile (the same function Business Details uses)
* rather than a parallel update path — googlePlaceId lives on Business.
* Deliberately no format regex on googlePlaceId beyond trimming — Google's
* own Place ID formats vary (place, postal code, plus-code IDs all look
* different) and this app has no need to validate more than "the owner
* typed something."
*
* Writes an audit log only when the Place ID actually changed — existing
* imported Review rows are deliberately left as-is when it changes (kept
* for history/audit, per docs/phase-10-summary.md's "Google Place ID"
* section); the next sync (scheduled or manual, via syncReviewsNowAction)
* naturally reconciles what's currently associated with the new Place ID.
* Writes an audit log only when the Place ID actually changed; the next
* sync (scheduled or manual, via syncReviewsNowAction) naturally picks up
* whatever rating/count the new Place ID has.
*/
export async function updateReviewsSettingsAction(input: {
googlePlaceId: string | null;
reviewsMinRating: number | null;
reviewsExcludeRatingOnly: boolean;
}): Promise<ReviewsActionResult> {
export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise<ReviewsActionResult> {
const { businessId, userId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
@@ -83,11 +75,6 @@ export async function updateReviewsSettingsAction(input: {
return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." };
}
await updateBusinessSettings(prisma, {
businessId,
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
});
if (placeIdChanged) {
await writeAuditLog(prisma, {
actorUserId: userId,
@@ -101,16 +88,7 @@ export async function updateReviewsSettingsAction(input: {
return { success: true };
}
export async function setReviewHiddenAction(reviewId: string, isHidden: boolean): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const result = await setReviewHidden(prisma, { reviewId, businessId, isHidden });
if (!result.success) return { success: false, error: "That review couldn't be found." };
return { success: true };
}
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see results immediately after entering a Place ID for the first time. */
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see the rating/count immediately after entering a Place ID for the first time. */
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
+48 -202
View File
@@ -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&apos;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&apos;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>
);
}