Skip to content

Commit 4cb0802

Browse files
committed
feat: add testimonial approval page and corresponding API integration tests
1 parent 7b61b23 commit 4cb0802

2 files changed

Lines changed: 58 additions & 19 deletions

File tree

src/app/approve-testimonial/page.tsx

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState, Suspense } from "react";
3+
import { useEffect, useState, useRef, Suspense } from "react";
44
import { useSearchParams } from "next/navigation";
55
import Image from "next/image";
66
import Link from "next/link";
@@ -75,6 +75,25 @@ function StarRating({
7575
);
7676
}
7777

78+
// ─── Shared Layout Shell & Card (Top-Level to prevent React remount on state change) ───
79+
function Shell({ children }: { children: React.ReactNode }) {
80+
return (
81+
<div className="w-full h-screen h-[100dvh] max-h-[100dvh] bg-gradient-to-br from-[#f8f8f6] via-[#f4f4f2] to-[#ececea] flex flex-col items-center justify-center font-sans p-4 sm:p-6 pb-[calc(env(safe-area-inset-bottom,0px)+1.25rem)] overflow-hidden">
82+
{children}
83+
</div>
84+
);
85+
}
86+
87+
function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
88+
return (
89+
<div
90+
className={`w-full max-w-lg bg-white rounded-[24px] sm:rounded-[28px] shadow-[0_12px_45px_rgba(0,0,0,0.10)] border border-black/[0.06] flex flex-col max-h-[calc(100dvh-2.5rem)] sm:max-h-[85vh] overflow-hidden animate-fade-in-up transition-all ${className}`}
91+
>
92+
{children}
93+
</div>
94+
);
95+
}
96+
7897
function ApproveTestimonialContent() {
7998
const searchParams = useSearchParams();
8099
const rawTokenParam = searchParams.get("token") || "";
@@ -98,6 +117,20 @@ function ApproveTestimonialContent() {
98117
const [rating, setRating] = useState(5);
99118
const [hoverRating, setHoverRating] = useState<number | null>(null);
100119

120+
const editContainerRef = useRef<HTMLDivElement>(null);
121+
const nameInputRef = useRef<HTMLInputElement>(null);
122+
123+
const toggleEditMode = () => {
124+
const nextState = !isEditing;
125+
setIsEditing(nextState);
126+
if (nextState) {
127+
setTimeout(() => {
128+
editContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
129+
nameInputRef.current?.focus();
130+
}, 50);
131+
}
132+
};
133+
101134
const verifyToken = () => {
102135
if (!token) {
103136
setLoading(false);
@@ -159,21 +192,6 @@ function ApproveTestimonialContent() {
159192
}
160193
};
161194

162-
// ─── Shared page shell ───────────────────────────────────────────────────
163-
const Shell = ({ children }: { children: React.ReactNode }) => (
164-
<div className="w-full h-screen h-[100dvh] max-h-[100dvh] bg-gradient-to-br from-[#f8f8f6] via-[#f4f4f2] to-[#ececea] flex flex-col items-center justify-center font-sans p-4 sm:p-6 pb-[calc(env(safe-area-inset-bottom,0px)+1.25rem)] overflow-hidden">
165-
{children}
166-
</div>
167-
);
168-
169-
const Card = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => (
170-
<div
171-
className={`w-full max-w-lg bg-white rounded-[24px] sm:rounded-[28px] shadow-[0_12px_45px_rgba(0,0,0,0.10)] border border-black/[0.06] flex flex-col max-h-[calc(100dvh-2.5rem)] sm:max-h-[85vh] overflow-hidden animate-fade-in-up transition-all ${className}`}
172-
>
173-
{children}
174-
</div>
175-
);
176-
177195
// ─── Loading ─────────────────────────────────────────────────────────────
178196
if (loading) {
179197
return (
@@ -388,7 +406,7 @@ function ApproveTestimonialContent() {
388406
</span>
389407
<button
390408
type="button"
391-
onClick={() => setIsEditing(!isEditing)}
409+
onClick={toggleEditMode}
392410
className="inline-flex items-center gap-1.5 text-xs font-semibold text-neutral-700 hover:text-neutral-900 bg-neutral-100 hover:bg-neutral-200 px-3 py-1.5 rounded-xl border border-neutral-200/80 transition cursor-pointer active:scale-95"
393411
>
394412
{isEditing ? (
@@ -400,12 +418,13 @@ function ApproveTestimonialContent() {
400418
</div>
401419

402420
{isEditing ? (
403-
<div className="space-y-3.5 p-5 bg-neutral-50/80 rounded-2xl border border-neutral-200/80 text-left">
421+
<div ref={editContainerRef} className="space-y-3.5 p-5 bg-neutral-50/80 rounded-2xl border border-neutral-200/80 text-left">
404422
<div>
405423
<label className="block text-xs font-bold text-neutral-600 mb-1.5 uppercase tracking-wider font-mono">
406424
Your Full Name
407425
</label>
408426
<input
427+
ref={nameInputRef}
409428
type="text"
410429
value={authorName}
411430
onChange={(e) => setAuthorName(e.target.value)}

tests/api.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2-
import { publicFormSchema, magicLinkRequestSchema } from "../src/lib/validation/schemas";
2+
import { publicFormSchema, magicLinkRequestSchema, magicLinkApproveSchema } from "../src/lib/validation/schemas";
33
import { sanitizeHtml, sanitizePlainText } from "../src/lib/security/sanitizer";
44
import { validateVideoUrl } from "../src/lib/security/video-url";
55
import { generateMagicLinkToken, hashMagicLinkToken, normalizeToken } from "../src/lib/tokens/magic-link";
@@ -36,6 +36,26 @@ describe("API Security & Validation Suite", () => {
3636
});
3737
expect(result.success).toBe(false);
3838
});
39+
40+
it("validates valid magic link testimonial approval payload", () => {
41+
const result = magicLinkApproveSchema.safeParse({
42+
token: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
43+
authorName: "John Doe Edited",
44+
authorTitle: "Lead Designer",
45+
content: "Working with this team was extraordinary and transformed our client intake.",
46+
rating: 5,
47+
});
48+
expect(result.success).toBe(true);
49+
});
50+
51+
it("rejects magic link approval with token length under 32 characters", () => {
52+
const result = magicLinkApproveSchema.safeParse({
53+
token: "short_token",
54+
authorName: "John Doe",
55+
content: "Approved text",
56+
});
57+
expect(result.success).toBe(false);
58+
});
3959
});
4060

4161
describe("2. DOMPurify HTML Sanitization", () => {

0 commit comments

Comments
 (0)