diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..638b55d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +scaffold-tmp +.next +.git +.gitignore +*.log +.env*.local +.env.development +npm-debug.log* +.DS_Store +.planning diff --git a/.env.example b/.env.example index 4bfd067..ee8ebec 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5879b03 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..84c06d2 --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/next.config.ts b/next.config.ts index 6418775..2ef615c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,7 @@ function getSupabaseHostname(): string { } const nextConfig: NextConfig = { + output: "standalone", allowedDevOrigins: ["192.168.1.92"], images: { remotePatterns: [ diff --git a/prisma/enable_rls_ad_interactions.sql b/prisma/enable_rls_ad_interactions.sql new file mode 100644 index 0000000..82655cc --- /dev/null +++ b/prisma/enable_rls_ad_interactions.sql @@ -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; diff --git a/prisma/enable_rls_invitecode.sql b/prisma/enable_rls_invitecode.sql new file mode 100644 index 0000000..f2be850 --- /dev/null +++ b/prisma/enable_rls_invitecode.sql @@ -0,0 +1 @@ +ALTER TABLE "InviteCode" ENABLE ROW LEVEL SECURITY; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4975db9..5b605ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(app)/invite/page.tsx b/src/app/(app)/invite/page.tsx new file mode 100644 index 0000000..7ee0aa7 --- /dev/null +++ b/src/app/(app)/invite/page.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+
+

Freunde einladen

+

+ Du hast {available.length} von {codes.length} Einladungen verfügbar. + Teile einen Link — jeder Code kann nur einmal verwendet werden. +

+
+ + {codes.length === 0 ? ( +
+ Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten. +
+ ) : ( +
+ {codes.map((code) => { + const isUsed = !!code.usedById || code.revoked; + return ( +
+ {/* Status icon */} +
+ {isUsed ? ( + + ) : ( + + )} +
+ + {/* Code */} + + {code.code} + + + {/* Status label */} + {isUsed ? ( + + {code.revoked ? "Widerrufen" : "Verwendet"} + {code.usedAt && !code.revoked + ? ` · ${new Date(code.usedAt).toLocaleDateString("de-DE")}` + : ""} + + ) : ( + + )} +
+ ); + })} +
+ )} + + {used.length > 0 && ( +

+ {used.length} von {codes.length} Codes bereits verwendet. +

+ )} +
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 6ef3e17..7f388d9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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([ diff --git a/src/app/api/auth/consume-invite/route.ts b/src/app/api/auth/consume-invite/route.ts new file mode 100644 index 0000000..50bd480 --- /dev/null +++ b/src/app/api/auth/consume-invite/route.ts @@ -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, + })), + }); + } + }); +} diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx new file mode 100644 index 0000000..05acf51 --- /dev/null +++ b/src/app/join/page.tsx @@ -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 ( +
+
+ {/* Logo / brand */} +
+

PawFeed

+

Alpha · Nur auf Einladung

+
+ +
+

+ Einladungscode eingeben +

+

+ PawFeed ist aktuell nur per Einladung zugänglich. Du brauchst einen Code von einem Mitglied. +

+ +
+
+ + {errorMessage && ( +

{errorMessage}

+ )} +
+ +
+
+ +

+ Noch kein Code? Bitte einen Freund, der PawFeed-Mitglied ist. +

+
+
+ ); +} diff --git a/src/app/p/[secret]/invites/page.tsx b/src/app/p/[secret]/invites/page.tsx new file mode 100644 index 0000000..54864b1 --- /dev/null +++ b/src/app/p/[secret]/invites/page.tsx @@ -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(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 ( +
+
+
+

Invite Codes

+

+ {available.length} verfügbar · {used.length} verwendet · {revoked.length} widerrufen +

+
+ +
+ + {isLoading ? ( +
+ +
+ ) : codes.length === 0 ? ( +

+ Noch keine Codes — oben einen Root-Code erstellen. +

+ ) : ( +
+ {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 ( +
+ {/* Status icon */} +
+ {isRevoked ? ( + + ) : isUsed ? ( + + ) : ( + + )} +
+ + {/* Code */} + + {code.code} + + + {/* Issuer */} + + {issuerName ? `von ${issuerName}` : "Root"} + + + {/* Status badge */} + {isRevoked ? ( + Widerrufen + ) : isUsed ? ( + + Verwendet · {code.usedAt ? new Date(code.usedAt).toLocaleDateString("de-DE") : ""} + + ) : ( + Verfügbar + )} + + {/* Actions */} +
+ {!isUsed && !isRevoked && ( + + )} + {!isRevoked && !isUsed && ( + + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index 92a8165..bc3c4b7 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -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 }, ]; diff --git a/src/components/feed/AdCard.tsx b/src/components/feed/AdCard.tsx index 8995eb9..cdce9ee 100644 --- a/src/components/feed/AdCard.tsx +++ b/src/components/feed/AdCard.tsx @@ -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 ( -
- {/* Header — mirrors PostCard layout */} -
- {/* Sponsor "avatar" — orange circle with paw */} -
- -
+ // ── Interaction data ──────────────────────────────────────────────────────── + const { data: feedData } = useQuery( + trpc.ads.getFeedData.queryOptions( + { adId: ad.id, petId: activePetId }, + { staleTime: 30_000 } + ) + ); - {/* Name + badge row */} -
-
-

- {ad.title} -

- - Sponsored - + 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 ( + <> +
+ {/* Header */} +
+
+
+ +
+
+

+ {ad.title} +

+ + Sponsored + +
+ {ad.targetUrl && ( +

+ {ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")} +

+ )} +
+ {ad.targetUrl && ( -

- {ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")} -

+ + )}
- {/* External link button — mirrors options button position */} - {ad.targetUrl && ( + {/* Image */} + {imageUrl && ( - )} -
- {/* Image area — same dimensions as PostCard single image */} - {imageUrl && ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {ad.title} - - )} + {/* Caption */} + {ad.caption && ( +
+

{ad.caption}

+
+ )} - {/* Footer — caption only, mirrors PostCard footer */} - {ad.caption && ( -
-

{ad.caption}

+ {/* Action bar — mirrors PostCard */} +
+ {/* Paw / Reaction */} + + + {/* Comment */} + + + {/* Repost */} +
- )} -
+ + + {/* Comment sheet */} + + + ); +} + +// ── 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(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 ( + + + + Kommentare + + + {/* Comment list */} +
+ {isLoading ? ( +
+ +
+ ) : comments.length === 0 ? ( +

+ Noch keine Kommentare — sei der Erste! +

+ ) : ( + comments.map((c) => { + const avatarUrl = getAvatarUrl(c.pet.avatarKey); + return ( +
+ + {avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {c.pet.name} + ) : ( + + {c.pet.name.slice(0, 2).toUpperCase()} + + )} + +
+
+ {c.pet.name} + + {formatRelative(c.createdAt)} + +
+

{c.body}

+
+
+ ); + }) + )} +
+ + {/* Compose */} +
+