feat: add feedback system with modal, api endpoints, and stats tracking
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { createFeedback } from '@/lib/db-actions';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const message = body?.message?.trim();
|
||||||
|
|
||||||
|
if (!message || message.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > 2000) {
|
||||||
|
return NextResponse.json({ error: 'Message is too long' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFeedback({ message, userId: userId ?? undefined });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPagesGeneratedLast24Hours } from '@/lib/db-actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const pagesLast24h = await getPagesGeneratedLast24Hours();
|
||||||
|
return NextResponse.json({ pagesLast24h });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error);
|
||||||
|
return NextResponse.json({ pagesLast24h: 0 }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MessageSquare, ArrowRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface FeedbackModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: message.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMessage("");
|
||||||
|
setStatus("idle");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-md">
|
||||||
|
<DialogHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4">
|
||||||
|
<div className="w-14 h-14 glass-panel rounded-full flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-6 h-6 text-indigo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl text-center text-white">
|
||||||
|
Share your feedback
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center text-muted-foreground">
|
||||||
|
What do you think? Any bugs, ideas, or feature requests are welcome.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{status === "success" ? (
|
||||||
|
<div className="mt-4 p-4 glass-panel rounded-lg text-center">
|
||||||
|
<p className="text-white text-sm font-medium">Thanks for your feedback!</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-1">We really appreciate it.</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-4 bg-white hover:bg-neutral-200 text-black"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<Textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Your feedback..."
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
className="bg-secondary border-border/50 text-white placeholder-muted-foreground resize-none"
|
||||||
|
/>
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-red-400 text-xs">Something went wrong. Please try again.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 text-muted-foreground hover:text-white hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!message.trim() || status === "loading"}
|
||||||
|
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black"
|
||||||
|
>
|
||||||
|
{status === "loading" ? "Sending..." : "Send Feedback"}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { TOGETHER_LINK } from "@/lib/utils";
|
import { TOGETHER_LINK } from "@/lib/utils";
|
||||||
import { Github } from "lucide-react";
|
import { Github, MessageSquare } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { FeedbackModal } from "@/components/feedback-modal";
|
||||||
|
|
||||||
function XIcon({ className }: { className?: string }) {
|
function XIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -11,7 +15,10 @@ function XIcon({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<footer className="h-8 border-t border-border/50 bg-background flex items-center justify-between px-6 text-[10px] text-muted-foreground select-none">
|
<footer className="h-8 border-t border-border/50 bg-background flex items-center justify-between px-6 text-[10px] text-muted-foreground select-none">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>
|
<span>
|
||||||
@@ -27,6 +34,13 @@ export function Footer() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFeedback(true)}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-border/50 hover:border-border hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
Got ideas? Tell us
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/nutlope/make-comics"
|
href="https://github.com/nutlope/make-comics"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -45,5 +59,8 @@ export function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<FeedbackModal isOpen={showFeedback} onClose={() => setShowFeedback(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { TOGETHER_LINK } from "@/lib/utils";
|
import { TOGETHER_LINK } from "@/lib/utils";
|
||||||
|
|
||||||
export function LandingHero() {
|
export function LandingHero() {
|
||||||
|
const [pagesLast24h, setPagesLast24h] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/stats")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled && data && typeof data.pagesLast24h === "number") {
|
||||||
|
setPagesLast24h(data.pagesLast24h);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roundedCount =
|
||||||
|
pagesLast24h !== null && pagesLast24h >= 10
|
||||||
|
? Math.floor(pagesLast24h / 10) * 10
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative py-8 sm:py-12 md:py-16 lg:py-0">
|
<header className="relative py-8 sm:py-12 md:py-16 lg:py-0">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
@@ -28,6 +51,16 @@ export function LandingHero() {
|
|||||||
Describe your scene, choose a style, and let AI render professional
|
Describe your scene, choose a style, and let AI render professional
|
||||||
comic panels instantly.
|
comic panels instantly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{roundedCount !== null && (
|
||||||
|
<p className="text-muted-foreground leading-relaxed max-w-md mx-auto lg:mx-0 tracking-[-0.02em] px-4 sm:px-0 text-sm mt-2">
|
||||||
|
More than{" "}
|
||||||
|
<span className="text-indigo font-semibold">
|
||||||
|
{roundedCount.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
comic pages have been generated in the last 24 hours.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "feedback" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
+16
-2
@@ -1,6 +1,6 @@
|
|||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { stories, pages, type Story, type Page } from './schema';
|
import { stories, pages, feedback, type Story, type Page, type Feedback } from './schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, gte, isNotNull, sql } from 'drizzle-orm';
|
||||||
import { generateComicSlug } from './slug-generator';
|
import { generateComicSlug } from './slug-generator';
|
||||||
|
|
||||||
export async function createStory(data: { title: string; description?: string; userId: string; style?: string; usesOwnApiKey?: boolean }): Promise<Story> {
|
export async function createStory(data: { title: string; description?: string; userId: string; style?: string; usesOwnApiKey?: boolean }): Promise<Story> {
|
||||||
@@ -150,3 +150,17 @@ export async function deletePage(pageId: string): Promise<void> {
|
|||||||
export async function deleteStory(storyId: string): Promise<void> {
|
export async function deleteStory(storyId: string): Promise<void> {
|
||||||
await db.delete(stories).where(eq(stories.id, storyId));
|
await db.delete(stories).where(eq(stories.id, storyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createFeedback(data: { message: string; userId?: string }): Promise<Feedback> {
|
||||||
|
const [entry] = await db.insert(feedback).values(data).returning();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPagesGeneratedLast24Hours(): Promise<number> {
|
||||||
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(pages)
|
||||||
|
.where(and(isNotNull(pages.generatedImageUrl), gte(pages.createdAt, since)));
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, integer, timestamp, uuid, jsonb, boolean } from 'drizzle-orm/pg-core';
|
import { pgTable, text, integer, timestamp, uuid, jsonb, boolean, serial } from 'drizzle-orm/pg-core';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
// Stories table
|
// Stories table
|
||||||
@@ -38,7 +38,18 @@ export const pagesRelations = relations(pages, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Feedback table
|
||||||
|
export const feedback = pgTable('feedback', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
userId: text('user_id'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
export type Feedback = typeof feedback.$inferSelect;
|
||||||
|
export type NewFeedback = typeof feedback.$inferInsert;
|
||||||
|
|
||||||
export type Story = typeof stories.$inferSelect;
|
export type Story = typeof stories.$inferSelect;
|
||||||
export type NewStory = typeof stories.$inferInsert;
|
export type NewStory = typeof stories.$inferInsert;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user