feat: ad engagement, invite gate, sidebar footer, docker setup

Ad interactions (reactions/comments/reposts on ads):
- Schema: AdReaction, AdComment, AdRepost models with RLS enabled
- tRPC: new ads router (getFeedData, toggleReaction, addComment, listComments, toggleRepost)
- AdCard: fully interactive card with paw, comment sheet, repost — mirrors PostCard style
- getActiveAds includes _count for advertiser engagement metrics

Invite system enforcement:
- proxy.ts: /sign-up blocked without pf_invite cookie
- INVITE_REQUIRED=false env var disables gate for post-beta live launch
- Full invite flow: /join → cookie → /sign-up → consume-invite → 3 codes issued to new user
- Admin: listInviteCodes, createRootInvite, revokeInvite procedures

Sidebar mini-footer:
- Impressum + Datenschutz links always visible at sidebar bottom
- PawFeed Alpha-Test copyright line

Docker self-hosting:
- docker/Dockerfile: 3-stage build using Next.js standalone output
- docker/docker-compose.yml: env_file + build-args for NEXT_PUBLIC_ vars
- .dockerignore at project root
- next.config.ts: output standalone for minimal image
- .env.example: documented INVITE_REQUIRED
This commit is contained in:
admin committed 2026-06-21 18:12:13 +02:00
1 parent a9793a5ad2
commit 5800e06cd9
24 files changed
+1162 -108

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
node_modules
scaffold-tmp
.next
.git
.gitignore
*.log
.env*.local
.env.development
npm-debug.log*
.DS_Store
.planning
+5
View File
@@ -29,3 +29,8 @@ SUPABASE_SERVICE_ROLE_KEY="eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ADMIN_SECRET="your-random-uuid-here"
# ADMIN_OWNER_ID: Your Clerk user ID (Clerk dashboard). Bootstraps the super-admin.
ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx"
# ── Invite Gate ───────────────────────────────────────────────
# Set to "false" to disable the invite requirement after the Alpha/Beta phase.
# Default: "true" (invite required — enforced in proxy.ts)
INVITE_REQUIRED="true"
+54
View File
@@ -0,0 +1,54 @@
# ──────────────────────────────────────────────
# Stage 1: install dependencies
# ──────────────────────────────────────────────
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ──────────────────────────────────────────────
# Stage 2: build
# ──────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Public env vars must be present at build time (baked into client bundle)
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate
RUN npm run build
# ──────────────────────────────────────────────
# Stage 3: production image
# ──────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy the standalone output (includes node_modules tree-shaken to minimum)
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+19
View File
@@ -0,0 +1,19 @@
services:
pawfeed:
build:
context: ..
dockerfile: docker/Dockerfile
args:
# These two are baked into the client bundle at build time.
# All other vars are injected at runtime via env_file below.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
image: pawfeed:latest
container_name: pawfeed
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- ../.env.local # copy .env.example → .env.local and fill in your values
environment:
NODE_ENV: production
+1
View File
@@ -9,6 +9,7 @@ function getSupabaseHostname(): string {
}
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.92"],
images: {
remotePatterns: [
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "AdReaction" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AdComment" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AdRepost" ENABLE ROW LEVEL SECURITY;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "InviteCode" ENABLE ROW LEVEL SECURITY;
+69 -8
View File
@@ -7,11 +7,27 @@ datasource db {
}
model Owner {
id String @id // = Clerk userId (e.g. "user_xxx")
createdAt DateTime @default(now())
pets Pet[]
adminRole AdminRole?
ban UserBan?
id String @id // = Clerk userId (e.g. "user_xxx")
createdAt DateTime @default(now())
invitedById String? // Clerk userId of the person who invited this owner
pets Pet[]
adminRole AdminRole?
ban UserBan?
issuedInvites InviteCode[] @relation("IssuedBy")
}
model InviteCode {
id String @id @default(cuid())
code String @unique
issuedBy Owner? @relation("IssuedBy", fields: [issuedById], references: [id])
issuedById String? // null = admin-generated root invite
usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet)
usedAt DateTime?
revoked Boolean @default(false)
createdAt DateTime @default(now())
@@index([issuedById])
@@index([code])
}
model Species {
@@ -80,6 +96,11 @@ model Pet {
healthCard HealthCard?
emergencyVet EmergencyVet?
// Ad interaction back-relations
adReactions AdReaction[] @relation("AdReactions")
adComments AdComment[] @relation("AdComments")
adReposts AdRepost[] @relation("AdReposts")
@@index([ownerId])
}
@@ -478,19 +499,59 @@ model Advertisement {
id String @id @default(cuid())
title String @db.VarChar(100)
caption String? @db.VarChar(500)
imageKey String? // Supabase Storage key
imageKey String?
targetUrl String? @db.VarChar(500)
isActive Boolean @default(true)
species String[] // [] = all species; ["Dog"] = dogs only (future targeting)
species String[]
startDate DateTime?
endDate DateTime?
createdBy String // ownerId of admin who created it
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reactions AdReaction[]
comments AdComment[]
reposts AdRepost[]
@@index([isActive, startDate, endDate])
}
model AdReaction {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdReactions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([adId, petId])
@@index([adId])
}
model AdComment {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdComments", fields: [petId], references: [id], onDelete: Cascade)
petId String
body String @db.VarChar(500)
createdAt DateTime @default(now())
@@index([adId])
}
model AdRepost {
id String @id @default(cuid())
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
adId String
pet Pet @relation("AdReposts", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([adId, petId])
@@index([adId])
}
model ModerationLog {
id String @id @default(cuid())
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useState } from "react";
import { Check, Copy, Loader2, UserCheck, Clock } from "lucide-react";
import { toast } from "sonner";
export default function InvitePage() {
const trpc = useTRPC();
const { data: codes = [], isLoading } = useQuery(trpc.invites.myInvites.queryOptions());
const [copiedId, setCopiedId] = useState<string | null>(null);
const available = codes.filter((c) => !c.usedById && !c.revoked);
const used = codes.filter((c) => c.usedById || c.revoked);
function copyLink(code: string, id: string) {
const url = `${window.location.origin}/join?code=${code}`;
void navigator.clipboard.writeText(url).then(() => {
setCopiedId(id);
toast.success("Link kopiert!");
setTimeout(() => setCopiedId(null), 2000);
});
}
if (isLoading) {
return (
<div className="flex justify-center py-24">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-lg mx-auto px-4 py-10 space-y-8">
<div>
<h1 className="text-xl font-semibold">Freunde einladen</h1>
<p className="text-sm text-muted-foreground mt-1">
Du hast {available.length} von {codes.length} Einladungen verfügbar.
Teile einen Link — jeder Code kann nur einmal verwendet werden.
</p>
</div>
{codes.length === 0 ? (
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground text-sm">
Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten.
</div>
) : (
<div className="space-y-2">
{codes.map((code) => {
const isUsed = !!code.usedById || code.revoked;
return (
<div
key={code.id}
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-colors ${
isUsed
? "border-border/50 bg-muted/30 opacity-60"
: "border-border bg-card hover:bg-accent/30"
}`}
>
{/* Status icon */}
<div className={`shrink-0 ${isUsed ? "text-muted-foreground" : "text-green-500"}`}>
{isUsed ? (
<UserCheck className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
</div>
{/* Code */}
<span className={`font-mono text-sm font-medium flex-1 ${isUsed ? "line-through text-muted-foreground" : ""}`}>
{code.code}
</span>
{/* Status label */}
{isUsed ? (
<span className="text-xs text-muted-foreground shrink-0">
{code.revoked ? "Widerrufen" : "Verwendet"}
{code.usedAt && !code.revoked
? ` · ${new Date(code.usedAt).toLocaleDateString("de-DE")}`
: ""}
</span>
) : (
<button
onClick={() => copyLink(code.code, code.id)}
className="shrink-0 flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copiedId === code.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedId === code.id ? "Kopiert" : "Link kopieren"}
</button>
)}
</div>
);
})}
</div>
)}
{used.length > 0 && (
<p className="text-xs text-muted-foreground">
{used.length} von {codes.length} Codes bereits verwendet.
</p>
)}
</div>
);
}
+7
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
import { cookies } from "next/headers";
import { createTRPCCaller } from "@/trpc/server";
import { Sidebar } from "@/components/layout/Sidebar";
import { MobileNav } from "@/components/layout/MobileNav";
@@ -28,6 +29,12 @@ export default async function AppLayout({
redirect("/log-in");
}
// Consume invite code on first login — cookie set by /join page before sign-up
const cookieStore = await cookies();
if (cookieStore.get("pf_invite")?.value) {
redirect("/api/auth/consume-invite");
}
// Fetch the owner's pets via tRPC server caller (scoped by userId)
const caller = await createTRPCCaller();
const [pets, ban] = await Promise.all([
+56
View File
@@ -0,0 +1,56 @@
import { auth } from "@clerk/nextjs/server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
export async function GET() {
const { userId } = await auth();
if (!userId) redirect("/log-in");
const cookieStore = await cookies();
const inviteCode = cookieStore.get("pf_invite")?.value;
if (inviteCode) {
await consumeInviteCode(userId, inviteCode);
cookieStore.delete("pf_invite");
}
redirect("/");
}
async function consumeInviteCode(userId: string, code: string) {
const invite = await prisma.inviteCode.findUnique({ where: { code } });
if (!invite || invite.usedById || invite.revoked) return;
await prisma.$transaction(async (tx) => {
await tx.inviteCode.update({
where: { code },
data: { usedById: userId, usedAt: new Date() },
});
await tx.owner.upsert({
where: { id: userId },
create: {
id: userId,
invitedById: invite.issuedById ?? null,
},
update: {
invitedById: invite.issuedById ?? undefined,
},
});
const existingCodes = await tx.inviteCode.count({
where: { issuedById: userId },
});
if (existingCodes === 0) {
await tx.inviteCode.createMany({
data: Array.from({ length: INVITE_CODES_PER_USER }, () => ({
code: generateInviteCode(),
issuedById: userId,
})),
});
}
});
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
interface JoinPageProps {
searchParams: Promise<{ code?: string; error?: string }>;
}
async function submitCode(formData: FormData) {
"use server";
const code = (formData.get("code") as string | null)?.trim().toUpperCase();
if (!code) redirect("/join?error=missing");
const invite = await prisma.inviteCode.findUnique({ where: { code } });
if (!invite || invite.usedById || invite.revoked) {
redirect(`/join?error=invalid`);
}
const cookieStore = await cookies();
cookieStore.set("pf_invite", code, {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60, // 1 hour
path: "/",
});
redirect("/sign-up");
}
export default async function JoinPage({ searchParams }: JoinPageProps) {
const { code: codeParam, error } = await searchParams;
// If a code is in the URL, pre-validate and set cookie immediately
if (codeParam) {
const code = codeParam.trim().toUpperCase();
const invite = await prisma.inviteCode.findUnique({ where: { code } });
if (invite && !invite.usedById && !invite.revoked) {
const cookieStore = await cookies();
cookieStore.set("pf_invite", code, {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60,
path: "/",
});
redirect("/sign-up");
}
// invalid code in URL — fall through and show form with error
}
const errorMessage =
error === "invalid" ? "Ungültiger oder bereits verwendeter Einladungscode."
: error === "missing" ? "Bitte gib einen Einladungscode ein."
: codeParam ? "Dieser Einladungscode ist ungültig oder wurde bereits verwendet."
: null;
return (
<main className="min-h-screen flex items-center justify-center bg-zinc-950 px-4">
<div className="w-full max-w-sm">
{/* Logo / brand */}
<div className="mb-8 text-center">
<p className="text-2xl font-bold text-zinc-100 tracking-tight">PawFeed</p>
<p className="text-sm text-zinc-500 mt-1">Alpha · Nur auf Einladung</p>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-6 shadow-xl">
<h1 className="text-lg font-semibold text-zinc-100 mb-1">
Einladungscode eingeben
</h1>
<p className="text-sm text-zinc-500 mb-5">
PawFeed ist aktuell nur per Einladung zugänglich. Du brauchst einen Code von einem Mitglied.
</p>
<form action={submitCode} className="space-y-4">
<div>
<input
name="code"
type="text"
placeholder="z.B. PAWS-X7K2"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2.5 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-orange-500 transition-shadow uppercase"
/>
{errorMessage && (
<p className="mt-2 text-xs text-red-400">{errorMessage}</p>
)}
</div>
<button
type="submit"
className="w-full rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
>
Weiter
</button>
</form>
</div>
<p className="mt-6 text-center text-xs text-zinc-600">
Noch kein Code? Bitte einen Freund, der PawFeed-Mitglied ist.
</p>
</div>
</main>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Plus, Loader2, Copy, Check, Ban, UserCheck, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
export default function AdminInvitesPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data: codes = [], isLoading } = useQuery(
trpc.admin.listInviteCodes.queryOptions({ limit: 200 })
);
const invalidate = () =>
void qc.invalidateQueries({ queryKey: trpc.admin.listInviteCodes.queryKey() });
const createRoot = useMutation(
trpc.admin.createRootInvite.mutationOptions({
onSuccess: () => {
invalidate();
toast.success("Root-Einladung erstellt");
},
})
);
const revoke = useMutation(
trpc.admin.revokeInvite.mutationOptions({
onSuccess: () => {
invalidate();
toast.success("Code widerrufen");
},
})
);
function copyLink(code: string, id: string) {
const url = `${window.location.origin}/join?code=${code}`;
void navigator.clipboard.writeText(url).then(() => {
setCopiedId(id);
toast.success("Link kopiert");
setTimeout(() => setCopiedId(null), 2000);
});
}
const available = codes.filter((c) => !c.usedById && !c.revoked);
const used = codes.filter((c) => c.usedById && !c.revoked);
const revoked = codes.filter((c) => c.revoked);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Invite Codes</h1>
<p className="text-sm text-zinc-500 mt-0.5">
{available.length} verfügbar · {used.length} verwendet · {revoked.length} widerrufen
</p>
</div>
<Button
size="sm"
onClick={() => createRoot.mutate()}
disabled={createRoot.isPending}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{createRoot.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
) : (
<Plus className="h-4 w-4 mr-1.5" />
)}
Root-Code erstellen
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : codes.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">
Noch keine Codes — oben einen Root-Code erstellen.
</p>
) : (
<div className="space-y-2">
{codes.map((code) => {
const isUsed = !!code.usedById;
const isRevoked = code.revoked;
const issuerName = code.issuedBy?.pets?.[0]?.name ?? (code.issuedById ? code.issuedById.slice(0, 12) : null);
return (
<div
key={code.id}
className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${
isRevoked
? "border-zinc-800 bg-zinc-900/50 opacity-50"
: isUsed
? "border-zinc-800 bg-zinc-900"
: "border-zinc-700 bg-zinc-900"
}`}
>
{/* Status icon */}
<div className={`shrink-0 ${isRevoked ? "text-red-500" : isUsed ? "text-zinc-500" : "text-green-500"}`}>
{isRevoked ? (
<Ban className="h-4 w-4" />
) : isUsed ? (
<UserCheck className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
</div>
{/* Code */}
<span className={`font-mono text-sm font-medium flex-1 ${isRevoked ? "line-through text-zinc-600" : "text-zinc-100"}`}>
{code.code}
</span>
{/* Issuer */}
<span className="text-xs text-zinc-600 shrink-0">
{issuerName ? `von ${issuerName}` : "Root"}
</span>
{/* Status badge */}
{isRevoked ? (
<Badge variant="secondary" className="text-[10px] shrink-0">Widerrufen</Badge>
) : isUsed ? (
<Badge variant="secondary" className="text-[10px] shrink-0">
Verwendet · {code.usedAt ? new Date(code.usedAt).toLocaleDateString("de-DE") : ""}
</Badge>
) : (
<Badge className="bg-green-800 text-green-200 text-[10px] shrink-0">Verfügbar</Badge>
)}
{/* Actions */}
<div className="flex gap-1 shrink-0">
{!isUsed && !isRevoked && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-400 hover:text-zinc-100"
title="Link kopieren"
onClick={() => copyLink(code.code, code.id)}
>
{copiedId === code.id ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
)}
{!isRevoked && !isUsed && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300"
title="Code widerrufen"
onClick={() => revoke.mutate({ id: code.id })}
disabled={revoke.isPending}
>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
import Link from "next/link";
import { createTRPCCaller } from "@/trpc/server";
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag } from "lucide-react";
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag, Ticket } from "lucide-react";
interface AdminLayoutProps {
children: React.ReactNode;
@@ -42,6 +42,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/reports`, label: "Reports", Icon: Flag },
{ href: `${base}/users`, label: "Users", Icon: Users },
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
{ href: `${base}/invites`, label: "Invites", Icon: Ticket },
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
];
+356 -55
View File
@@ -1,7 +1,21 @@
"use client";
import { ExternalLink } from "lucide-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 { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { getMediaUrl } from "@/lib/media-url";
import { getAvatarUrl } from "@/lib/avatar-url";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
type AdCardAd = {
id: string;
@@ -11,74 +25,361 @@ type AdCardAd = {
imageKey: string | null;
};
export function AdCard({ ad }: { ad: AdCardAd }) {
function formatRelative(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "gerade eben";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString("de-DE");
}
interface AdCardProps {
ad: AdCardAd;
activePetId: string;
}
export function AdCard({ ad, activePetId }: AdCardProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const imageUrl = getMediaUrl(ad.imageKey);
return (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header — mirrors PostCard layout */}
<div className="flex items-center gap-3 px-4 py-3">
{/* Sponsor "avatar" — orange circle with paw */}
<div className="w-8 h-8 rounded-full bg-orange-500/15 border border-orange-400/30 flex items-center justify-center shrink-0">
<span className="text-base leading-none select-none" aria-hidden="true">🐾</span>
</div>
// ── Interaction data ────────────────────────────────────────────────────────
const { data: feedData } = useQuery(
trpc.ads.getFeedData.queryOptions(
{ adId: ad.id, petId: activePetId },
{ staleTime: 30_000 }
)
);
{/* Name + badge row */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-base font-semibold text-foreground leading-tight truncate">
{ad.title}
</p>
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
Sponsored
</span>
const [reacted, setReacted] = useState(feedData?.hasReacted ?? false);
const [reactionCount, setReactionCount] = useState(feedData?.reactionCount ?? 0);
const [reposted, setReposted] = useState(feedData?.hasReposted ?? false);
const [repostCount, setRepostCount] = useState(feedData?.repostCount ?? 0);
const [commentSheetOpen, setCommentSheetOpen] = useState(false);
// Sync state from server once data loads
useEffect(() => {
if (feedData) {
setReacted(feedData.hasReacted);
setReactionCount(feedData.reactionCount);
setReposted(feedData.hasReposted);
setRepostCount(feedData.repostCount);
}
}, [feedData]);
const invalidateFeedData = useCallback(() => {
void queryClient.invalidateQueries({
queryKey: trpc.ads.getFeedData.queryKey({ adId: ad.id, petId: activePetId }),
});
}, [queryClient, trpc, ad.id, activePetId]);
// ── Reaction ────────────────────────────────────────────────────────────────
const reactionMutation = useMutation(
trpc.ads.toggleReaction.mutationOptions({
onMutate: () => {
const next = !reacted;
setReacted(next);
setReactionCount((c) => (next ? c + 1 : Math.max(0, c - 1)));
},
onSuccess: (data) => {
setReacted(data.reacted);
setReactionCount(data.count);
},
onError: () => {
setReacted(feedData?.hasReacted ?? false);
setReactionCount(feedData?.reactionCount ?? 0);
toast.error("Fehler beim Reagieren");
},
onSettled: invalidateFeedData,
})
);
// ── Repost ──────────────────────────────────────────────────────────────────
const repostMutation = useMutation(
trpc.ads.toggleRepost.mutationOptions({
onMutate: () => {
const next = !reposted;
setReposted(next);
setRepostCount((c) => (next ? c + 1 : Math.max(0, c - 1)));
},
onSuccess: (data) => {
setReposted(data.reposted);
setRepostCount(data.count);
},
onError: () => {
setReposted(feedData?.hasReposted ?? false);
setRepostCount(feedData?.repostCount ?? 0);
toast.error("Fehler beim Reposten");
},
onSettled: invalidateFeedData,
})
);
return (
<>
<article 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">
<span className="text-base leading-none select-none" aria-hidden="true">🐾</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-base font-semibold text-foreground leading-tight truncate">
{ad.title}
</p>
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
Sponsored
</span>
</div>
{ad.targetUrl && (
<p className="text-xs text-muted-foreground truncate">
{ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}
</p>
)}
</div>
{ad.targetUrl && (
<p className="text-xs text-muted-foreground truncate">
{ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}
</p>
<a
href={ad.targetUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex items-center justify-center w-11 h-11 rounded-full hover:bg-muted transition-colors"
aria-label={`${ad.title} besuchen`}
>
<ExternalLink className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</a>
)}
</div>
{/* External link button — mirrors options button position */}
{ad.targetUrl && (
{/* Image */}
{imageUrl && (
<a
href={ad.targetUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex items-center justify-center w-11 h-11 rounded-full hover:bg-muted transition-colors"
aria-label={`${ad.title} besuchen`}
href={ad.targetUrl ?? undefined}
target={ad.targetUrl ? "_blank" : undefined}
rel={ad.targetUrl ? "noopener noreferrer sponsored" : undefined}
className={ad.targetUrl ? "block" : undefined}
tabIndex={ad.targetUrl ? 0 : -1}
>
<ExternalLink className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={ad.title}
className="w-full object-cover max-h-[560px]"
/>
</a>
)}
</div>
{/* Image area — same dimensions as PostCard single image */}
{imageUrl && (
<a
href={ad.targetUrl ?? undefined}
target={ad.targetUrl ? "_blank" : undefined}
rel={ad.targetUrl ? "noopener noreferrer sponsored" : undefined}
className={ad.targetUrl ? "block" : undefined}
tabIndex={ad.targetUrl ? 0 : -1}
aria-label={ad.targetUrl ? `${ad.title} besuchen` : undefined}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={ad.title}
className="w-full object-cover max-h-[560px]"
/>
</a>
)}
{/* Caption */}
{ad.caption && (
<div className="px-4 pt-3">
<p className="text-sm text-foreground">{ad.caption}</p>
</div>
)}
{/* Footer — caption only, mirrors PostCard footer */}
{ad.caption && (
<div className="px-4 py-3">
<p className="text-sm text-foreground">{ad.caption}</p>
{/* Action bar — mirrors PostCard */}
<div className="flex items-center gap-4 px-4 py-3">
{/* Paw / Reaction */}
<button
type="button"
onClick={() => {
if (reactionMutation.isPending) return;
reactionMutation.mutate({ adId: ad.id, petId: activePetId });
}}
disabled={reactionMutation.isPending}
aria-label={reacted ? "Reaktion entfernen" : "Auf Anzeige reagieren"}
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<PawPrint
className={`h-4 w-4 transition-colors ${
reacted
? "fill-orange-500 text-orange-500"
: "text-muted-foreground hover:text-orange-500"
}`}
aria-hidden="true"
/>
{reactionCount > 0 && (
<span className={reacted ? "text-orange-500" : "text-muted-foreground"}>
{reactionCount}
</span>
)}
</button>
{/* Comment */}
<button
type="button"
onClick={() => setCommentSheetOpen(true)}
aria-label="Kommentare öffnen"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
{(feedData?.commentCount ?? 0) > 0 && (
<span>{feedData?.commentCount}</span>
)}
</button>
{/* Repost */}
<button
type="button"
onClick={() => {
if (repostMutation.isPending) return;
repostMutation.mutate({ adId: ad.id, petId: activePetId });
}}
disabled={repostMutation.isPending}
aria-label={reposted ? "Repost entfernen" : "Anzeige reposten"}
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<Repeat2
className={`h-4 w-4 ${
reposted
? "text-green-500"
: "text-muted-foreground hover:text-green-500"
}`}
aria-hidden="true"
/>
{repostCount > 0 && (
<span className={reposted ? "text-green-500" : "text-muted-foreground"}>
{repostCount}
</span>
)}
</button>
</div>
)}
</article>
</article>
{/* Comment sheet */}
<AdCommentSheet
adId={ad.id}
petId={activePetId}
open={commentSheetOpen}
onOpenChange={setCommentSheetOpen}
/>
</>
);
}
// ── Inline comment sheet for ads ─────────────────────────────────────────────
interface AdCommentSheetProps {
adId: string;
petId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function AdCommentSheet({ adId, petId, open, onOpenChange }: AdCommentSheetProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [body, setBody] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { data: comments = [], isLoading } = useQuery(
trpc.ads.listComments.queryOptions({ adId }, { enabled: open })
);
const addMutation = useMutation(
trpc.ads.addComment.mutationOptions({
onSuccess: () => {
setBody("");
void queryClient.invalidateQueries({
queryKey: trpc.ads.listComments.queryKey({ adId }),
});
void queryClient.invalidateQueries({
queryKey: trpc.ads.getFeedData.queryKey({ adId, petId }),
});
},
onError: () => toast.error("Kommentar konnte nicht gesendet werden"),
})
);
const handleSubmit = () => {
const trimmed = body.trim();
if (!trimmed || addMutation.isPending) return;
addMutation.mutate({ adId, petId, body: trimmed });
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="h-[70vh] flex flex-col bg-background">
<SheetHeader className="shrink-0">
<SheetTitle>Kommentare</SheetTitle>
</SheetHeader>
{/* Comment list */}
<div className="flex-1 overflow-y-auto py-2 space-y-4 min-h-0">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
Noch keine Kommentare — sei der Erste!
</p>
) : (
comments.map((c) => {
const avatarUrl = getAvatarUrl(c.pet.avatarKey);
return (
<div key={c.id} className="flex gap-3 px-1">
<Avatar className="h-8 w-8 shrink-0">
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatarUrl} alt={c.pet.name} className="h-8 w-8 rounded-full object-cover" />
) : (
<AvatarFallback className="text-xs">
{c.pet.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
)}
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold">{c.pet.name}</span>
<span className="text-xs text-muted-foreground">
{formatRelative(c.createdAt)}
</span>
</div>
<p className="text-sm text-foreground mt-0.5 break-words">{c.body}</p>
</div>
</div>
);
})
)}
</div>
{/* Compose */}
<div className="shrink-0 border-t pt-3 pb-safe flex gap-2">
<Textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="Kommentar schreiben…"
rows={2}
maxLength={500}
className="resize-none flex-1"
/>
<Button
onClick={handleSubmit}
disabled={!body.trim() || addMutation.isPending}
size="sm"
className="self-end"
>
{addMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Senden"
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
+3 -3
View File
@@ -134,9 +134,9 @@ export function FeedList() {
{feedItems.map((item) =>
item.kind === "post" ? (
<PostCard key={item.post.id} post={{ ...item.post, createdAt: item.post.createdAt }} />
) : (
<AdCard key={item.key} ad={item.ad} />
)
) : activePetId ? (
<AdCard key={item.key} ad={item.ad} activePetId={activePetId} />
) : null
)}
</div>
+18
View File
@@ -13,6 +13,7 @@ import {
PawPrint,
ChevronDown,
Plus,
Ticket,
} from "lucide-react";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import { NotificationBell } from "@/components/notifications/NotificationBell";
@@ -47,6 +48,7 @@ const navLinks = [
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
{ href: "/messages", label: "Messages", icon: MessageCircle, active: true, special: false },
{ href: "/invite", label: "Einladen", icon: Ticket, active: true, special: false },
];
/**
@@ -207,6 +209,22 @@ export function Sidebar({ pets }: SidebarProps) {
</button>
</div>
{/* Legal mini-footer */}
<div className="px-4 pb-3 space-y-1">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/60">
<Link href="/impressum" className="hover:text-muted-foreground transition-colors">
Impressum
</Link>
<span aria-hidden="true">·</span>
<Link href="/datenschutz" className="hover:text-muted-foreground transition-colors">
Datenschutz
</Link>
</div>
<p className="text-[10px] text-muted-foreground/40">
PawFeed · Alpha-Test · © {new Date().getFullYear()}
</p>
</div>
{/* Bottom: owner identity */}
<div className="px-4 py-4 border-t border flex items-center gap-2">
<UserButton />
+13
View File
@@ -0,0 +1,13 @@
const PREFIXES = ["PAWS", "BARK", "PURR", "WOOF", "MEOW", "TAIL", "PUPS", "FLUF"];
const CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // no confusable chars (0, 1, I, O)
export function generateInviteCode(): string {
const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
const suffix = Array.from(
{ length: 4 },
() => CHARS[Math.floor(Math.random() * CHARS.length)]
).join("");
return `${prefix}-${suffix}`;
}
export const INVITE_CODES_PER_USER = 3;
-41
View File
@@ -1,41 +0,0 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
const isPublicRoute = createRouteMatcher([
"/log-in(.*)",
"/sign-up(.*)",
"/forgot-password(.*)",
"/reset-password(.*)",
"/verify-email(.*)",
"/health-card/(.*)",
"/impressum",
"/datenschutz",
"/api/webhooks/(.*)",
]);
export default clerkMiddleware(async (auth, request) => {
const { pathname } = request.nextUrl;
// Admin panel: validate secret path segment before anything else.
// Returns 404 (not 403) — the panel's existence must not be revealed.
if (pathname.startsWith("/p/")) {
const segments = pathname.split("/");
const providedSecret = segments[2] ?? "";
const adminSecret = process.env.ADMIN_SECRET ?? "";
if (!adminSecret || providedSecret !== adminSecret) {
return new NextResponse(null, { status: 404 });
}
}
if (!isPublicRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
+11
View File
@@ -10,12 +10,23 @@ const isPublicRoute = createRouteMatcher([
"/health-card/(.*)",
"/impressum",
"/datenschutz",
"/join(.*)",
"/api/webhooks/(.*)",
"/api/auth/(.*)",
]);
export default clerkMiddleware(async (auth, request) => {
const { pathname } = request.nextUrl;
// Invite gate: block /sign-up unless the visitor has a valid invite cookie.
// Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch.
if (pathname.startsWith("/sign-up")) {
const inviteRequired = process.env.INVITE_REQUIRED !== "false";
if (inviteRequired && !request.cookies.has("pf_invite")) {
return NextResponse.redirect(new URL("/join", request.url));
}
}
// Admin panel: validate secret path segment before anything else.
// Returns 404 (not 403) — the panel's existence must not be revealed.
if (pathname.startsWith("/p/")) {
+4
View File
@@ -18,6 +18,8 @@ import { videosRouter } from "@/trpc/routers/videos";
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
import { healthRouter } from "@/trpc/routers/health";
import { adminRouter } from "@/trpc/routers/admin";
import { invitesRouter } from "@/trpc/routers/invites";
import { adsRouter } from "@/trpc/routers/ads";
export const appRouter = router({
pets: petsRouter,
@@ -40,6 +42,8 @@ export const appRouter = router({
messages: messagesRouter,
health: healthRouter,
admin: adminRouter,
invites: invitesRouter,
ads: adsRouter,
});
// Export the type for use in the client
+36
View File
@@ -389,6 +389,9 @@ export const adminRouter = router({
OR: [{ startDate: null }, { startDate: { lte: now } }],
AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }],
},
include: {
_count: { select: { reactions: true, comments: true, reposts: true } },
},
orderBy: { createdAt: "desc" },
});
}),
@@ -434,4 +437,37 @@ export const adminRouter = router({
take: input.limit,
});
}),
// ─── Invite codes ─────────────────────────────────────────────────────────────
listInviteCodes: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.inviteCode.findMany({
orderBy: { createdAt: "desc" },
take: input.limit,
include: {
issuedBy: { select: { id: true, pets: { select: { name: true }, take: 1 } } },
},
});
}),
createRootInvite: protectedProcedure.mutation(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const { generateInviteCode } = await import("@/lib/invite-codes");
return ctx.prisma.inviteCode.create({
data: { code: generateInviteCode(), issuedById: null },
});
}),
revokeInvite: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
await ctx.prisma.inviteCode.update({
where: { id: input.id },
data: { revoked: true },
});
return { success: true };
}),
});
+96
View File
@@ -0,0 +1,96 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
export const adsRouter = router({
// Returns 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 [reactionCount, hasReacted, commentCount, repostCount, hasReposted] =
await Promise.all([
ctx.prisma.adReaction.count({ where: { adId: input.adId } }),
ctx.prisma.adReaction.findUnique({
where: { adId_petId: { adId: input.adId, petId: input.petId } },
select: { id: true },
}),
ctx.prisma.adComment.count({ where: { adId: input.adId } }),
ctx.prisma.adRepost.count({ where: { adId: input.adId } }),
ctx.prisma.adRepost.findUnique({
where: { adId_petId: { adId: input.adId, petId: input.petId } },
select: { id: true },
}),
]);
return {
reactionCount,
hasReacted: !!hasReacted,
commentCount,
repostCount,
hasReposted: !!hasReposted,
};
}),
toggleReaction: protectedProcedure
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.adReaction.findUnique({
where: { adId_petId: { adId: input.adId, petId: input.petId } },
});
if (existing) {
await ctx.prisma.adReaction.delete({ where: { id: existing.id } });
const count = await ctx.prisma.adReaction.count({ where: { adId: input.adId } });
return { reacted: false, count };
} else {
await ctx.prisma.adReaction.create({ data: { adId: input.adId, petId: input.petId } });
const count = await ctx.prisma.adReaction.count({ where: { adId: input.adId } });
return { reacted: true, count };
}
}),
listComments: protectedProcedure
.input(z.object({ adId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
return ctx.prisma.adComment.findMany({
where: { adId: input.adId },
include: {
pet: { select: { id: true, name: true, avatarKey: true } },
},
orderBy: { createdAt: "asc" },
take: 100,
});
}),
addComment: protectedProcedure
.input(
z.object({
adId: z.string().min(1),
petId: z.string().min(1),
body: z.string().min(1).max(500),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.adComment.create({
data: { adId: input.adId, petId: input.petId, body: input.body },
include: {
pet: { select: { id: true, name: true, avatarKey: true } },
},
});
}),
toggleRepost: protectedProcedure
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.adRepost.findUnique({
where: { adId_petId: { adId: input.adId, petId: input.petId } },
});
if (existing) {
await ctx.prisma.adRepost.delete({ where: { id: existing.id } });
const count = await ctx.prisma.adRepost.count({ where: { adId: input.adId } });
return { reposted: false, count };
} else {
await ctx.prisma.adRepost.create({ data: { adId: input.adId, petId: input.petId } });
const count = await ctx.prisma.adRepost.count({ where: { adId: input.adId } });
return { reposted: true, count };
}
}),
});
+11
View File
@@ -0,0 +1,11 @@
import { router, protectedProcedure } from "@/trpc/init";
export const invitesRouter = router({
myInvites: protectedProcedure.query(async ({ ctx }) => {
const codes = await ctx.prisma.inviteCode.findMany({
where: { issuedById: ctx.userId },
orderBy: { createdAt: "asc" },
});
return codes;
}),
});