feat(ads): track and display per-ad view counts for advertisers
Advertisers/admins had no way to see how many times an ad was actually shown — only engagement counts (reactions/comments/reposts) existed. Adds Advertisement.adViewCount, a denormalized counter matching the existing engagement-counter pattern. AdCard.tsx fires ads.recordView once per card, the first time it scrolls ≥50% into view (react-intersection-observer's triggerOnce, already a project dependency via FeedList's pagination sentinel) — matches standard "viewable impression" semantics rather than counting on mount (which would also count ads that render off-screen and are never actually seen). Not deduplicated per pet, unlike reactions/reposts — an impression counts every time an ad becomes visible, matching how ad platforms report view counts. Admin ads panel (/p/[secret]/ads) now shows view/reaction/comment/ repost counts inline on every ad row — the actual "results" advertisers want from a placement, previously not surfaced anywhere despite already existing on the Advertisement row for the other three counters.
This commit is contained in:
1 parent
0e02d3da71
commit
b4ef5bc195
5 files changed
+82
-5
No files matched your search
@@ -557,6 +557,11 @@ model Advertisement {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Denormalized engagement counters — see Post model comment for rationale.
|
||||
// adViewCount is incremented once per ad-card impression (client fires
|
||||
// ads.recordView the first time the card is ≥50% scrolled into view —
|
||||
// see AdCard.tsx), not deduplicated per pet, matching standard ad-impression
|
||||
// semantics rather than the per-pet-once pattern reactions/reposts use.
|
||||
adViewCount Int @default(0)
|
||||
adReactionCount Int @default(0)
|
||||
adCommentCount Int @default(0)
|
||||
adRepostCount Int @default(0)
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("ads.getFeedData", () => {
|
||||
const { caller, mockPrisma } = await createAdsCaller();
|
||||
|
||||
mockPrisma.advertisement.findUnique.mockResolvedValue({
|
||||
adViewCount: 240,
|
||||
adReactionCount: 12,
|
||||
adCommentCount: 3,
|
||||
adRepostCount: 1,
|
||||
@@ -63,6 +64,7 @@ describe("ads.getFeedData", () => {
|
||||
expect.objectContaining({ where: { id: "ad-1" } })
|
||||
);
|
||||
expect(result).toEqual({
|
||||
viewCount: 240,
|
||||
reactionCount: 12,
|
||||
hasReacted: true,
|
||||
commentCount: 3,
|
||||
@@ -72,6 +74,21 @@ describe("ads.getFeedData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ads.recordView", () => {
|
||||
it("increments adViewCount for the given ad", async () => {
|
||||
const { caller, mockPrisma } = await createAdsCaller();
|
||||
mockPrisma.advertisement.update.mockResolvedValue({ id: "ad-1", adViewCount: 1 });
|
||||
|
||||
const result = await caller.ads.recordView({ adId: "ad-1" });
|
||||
|
||||
expect(mockPrisma.advertisement.update).toHaveBeenCalledWith({
|
||||
where: { id: "ad-1" },
|
||||
data: { adViewCount: { increment: 1 } },
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ads.toggleReaction — ownership", () => {
|
||||
it("throws FORBIDDEN when petId does not belong to the caller (2026-07-21 audit fix)", async () => {
|
||||
const { caller, mockPrisma } = await createAdsCaller();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff, Eye, PawPrint, MessageCircle, Repeat2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -215,6 +215,26 @@ export default function AdminAdsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results — what advertisers actually want to see */}
|
||||
<div className="flex items-center gap-3 shrink-0 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1" title="Views">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{ad.adViewCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Reactions">
|
||||
<PawPrint className="h-3.5 w-3.5" />
|
||||
{ad.adReactionCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Comments">
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
{ad.adCommentCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Reposts">
|
||||
<Repeat2 className="h-3.5 w-3.5" />
|
||||
{ad.adRepostCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(ad)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ExternalLink, PawPrint, MessageCircle, Repeat2, Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -73,6 +74,17 @@ export function AdCard({ ad, activePetId }: AdCardProps) {
|
||||
});
|
||||
}, [queryClient, trpc, ad.id, activePetId]);
|
||||
|
||||
// ── Impression tracking ─────────────────────────────────────────────────────
|
||||
// Fires once, the first time the card is at least half-visible in the
|
||||
// viewport — matches the standard "viewable impression" threshold rather
|
||||
// than counting ads that only briefly touch the edge of the screen.
|
||||
const { ref: viewRef, inView } = useInView({ threshold: 0.5, triggerOnce: true });
|
||||
const recordViewMutation = useMutation(trpc.ads.recordView.mutationOptions());
|
||||
useEffect(() => {
|
||||
if (inView) recordViewMutation.mutate({ adId: ad.id });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inView, ad.id]);
|
||||
|
||||
// ── Reaction ────────────────────────────────────────────────────────────────
|
||||
const reactionMutation = useMutation(
|
||||
trpc.ads.toggleReaction.mutationOptions({
|
||||
@@ -117,7 +129,7 @@ export function AdCard({ ad, activePetId }: AdCardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<article ref={viewRef} className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="w-8 h-8 rounded-full bg-orange-500/15 border border-orange-400/30 flex items-center justify-center shrink-0">
|
||||
|
||||
+25
-2
@@ -4,14 +4,19 @@ import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
|
||||
export const adsRouter = router({
|
||||
// Returns reaction/comment/repost counts + whether the given pet has interacted
|
||||
// Returns view/reaction/comment/repost counts + whether the given pet has interacted
|
||||
getFeedData: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [ad, hasReacted, hasReposted] = await Promise.all([
|
||||
ctx.prisma.advertisement.findUnique({
|
||||
where: { id: input.adId },
|
||||
select: { adReactionCount: true, adCommentCount: true, adRepostCount: true },
|
||||
select: {
|
||||
adViewCount: true,
|
||||
adReactionCount: true,
|
||||
adCommentCount: true,
|
||||
adRepostCount: true,
|
||||
},
|
||||
}),
|
||||
ctx.prisma.adReaction.findUnique({
|
||||
where: { adId_petId: { adId: input.adId, petId: input.petId } },
|
||||
@@ -23,6 +28,7 @@ export const adsRouter = router({
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
viewCount: ad?.adViewCount ?? 0,
|
||||
reactionCount: ad?.adReactionCount ?? 0,
|
||||
hasReacted: !!hasReacted,
|
||||
commentCount: ad?.adCommentCount ?? 0,
|
||||
@@ -31,6 +37,23 @@ export const adsRouter = router({
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Records one ad-card impression. Called once per card render — from
|
||||
* AdCard.tsx, when the card first scrolls ≥50% into view (not deduplicated
|
||||
* per pet, matching standard ad-impression semantics — see schema comment).
|
||||
* Fire-and-forget from the caller's perspective; failures are non-fatal
|
||||
* (a missed view increment shouldn't surface as a user-visible error).
|
||||
*/
|
||||
recordView: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.advertisement.update({
|
||||
where: { id: input.adId },
|
||||
data: { adViewCount: { increment: 1 } },
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
toggleReaction: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
Reference in new issue
Block a user