diff --git a/apps/api/.env.example b/apps/api/.env.example index 12e24a40..3deafe1e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -30,3 +30,6 @@ ZEPTOMAIL_TOKEN=zeptomail-token # key for the encryption # can be created by running this: echo "$(openssl rand -hex 32)opensox$(openssl rand -hex 16)" ENCRYPTION_KEY=encryption-key + + +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 0c6722ac..99e58cf9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,4 +42,4 @@ "prisma": { "seed": "tsx prisma/seed.ts" } -} +} \ No newline at end of file diff --git a/apps/api/prisma/migrations/20251218121459_add_testimonials/migration.sql b/apps/api/prisma/migrations/20251218121459_add_testimonials/migration.sql new file mode 100644 index 00000000..672bd812 --- /dev/null +++ b/apps/api/prisma/migrations/20251218121459_add_testimonials/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Testimonial" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatar" TEXT NOT NULL, + "socialLink" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Testimonial_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Testimonial_userId_key" ON "Testimonial"("userId"); + +-- AddForeignKey +ALTER TABLE "Testimonial" ADD CONSTRAINT "Testimonial_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..10b1c062 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -42,6 +42,19 @@ model User { accounts Account[] payments Payment[] subscriptions Subscription[] + testimonial Testimonial? +} + +model Testimonial { + id String @id @default(cuid()) + userId String @unique + content String + name String + avatar String + socialLink String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Account { diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 782b4361..dde653c5 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -4,6 +4,7 @@ import { userRouter } from "./user.js"; import { projectRouter } from "./projects.js"; import { authRouter } from "./auth.js"; import { paymentRouter } from "./payment.js"; +import { testimonialRouter } from "./testimonial.js"; import { z } from "zod"; const testRouter = router({ @@ -21,6 +22,7 @@ export const appRouter = router({ project: projectRouter, auth: authRouter, payment: paymentRouter, + testimonial: testimonialRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/testimonial.ts b/apps/api/src/routers/testimonial.ts new file mode 100644 index 00000000..ebdafda5 --- /dev/null +++ b/apps/api/src/routers/testimonial.ts @@ -0,0 +1,129 @@ +import { router, protectedProcedure, publicProcedure } from "../trpc.js"; +import { z } from "zod"; +import { userService } from "../services/user.service.js"; +import { TRPCError } from "@trpc/server"; +import { validateAvatarUrl } from "../utils/avatar-validator.js"; + +export const testimonialRouter = router({ + getAll: publicProcedure.query(async ({ ctx }: any) => { + const testimonials = await ctx.db.prisma.testimonial.findMany({ + orderBy: { + createdAt: "desc", + }, + }); + + return testimonials; + }), + + getMyTestimonial: protectedProcedure.query(async ({ ctx }: any) => { + const userId = ctx.user.id; + + const { isPaidUser } = await userService.checkSubscriptionStatus( + ctx.db.prisma, + userId + ); + + if (!isPaidUser) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only premium users can submit testimonials", + }); + } + + const testimonial = await ctx.db.prisma.testimonial.findUnique({ + where: { userId }, + }); + + return { + testimonial, + }; + }), + + submit: protectedProcedure + .input( + z.object({ + name: z + .string() + .min(1, "Name is required") + .max(40, "Name must be at most 40 characters"), + content: z + .string() + .min(10, "Testimonial must be at least 10 characters") + .max(1500, "Testimonial must be at most 1500 characters"), + avatar: z.url(), + socialLink: z + .string() + .optional() + .refine( + (val) => { + if (!val || val === "") return true; + try { + const parsedUrl = new URL(val); + const supportedPlatforms = [ + "twitter.com", + "x.com", + "linkedin.com", + "instagram.com", + "youtube.com", + "youtu.be", + ]; + return supportedPlatforms.some( + (platform) => + parsedUrl.hostname === platform || + parsedUrl.hostname.endsWith("." + platform) + ); + } catch { + return false; + } + }, + { + message: + "Must be a valid Twitter/X, LinkedIn, Instagram, or YouTube URL", + } + ) + .or(z.literal("")), + }) + ) + .mutation(async ({ ctx, input }: any) => { + const userId = ctx.user.id; + + const { isPaidUser } = await userService.checkSubscriptionStatus( + ctx.db.prisma, + userId + ); + + if (!isPaidUser) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only premium users can submit testimonials", + }); + } + + const existingTestimonial = await ctx.db.prisma.testimonial.findUnique({ + where: { userId }, + }); + + if (existingTestimonial) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You have already submitted a testimonial. Testimonials cannot be edited once submitted.", + }); + } + + // Validate avatar URL with strict security checks + await validateAvatarUrl(input.avatar); + + const result = await ctx.db.prisma.testimonial.create({ + data: { + userId, + name: input.name, + content: input.content, + avatar: input.avatar, + socialLink: input.socialLink || null, + }, + }); + + return result; + }), +}); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 94205d01..051cd2f6 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -34,5 +34,5 @@ export const userRouter = router({ userId, input.completedSteps ); - }), + }), }); diff --git a/apps/api/src/services/payment.service.ts b/apps/api/src/services/payment.service.ts index 85afa82a..b3f99324 100644 --- a/apps/api/src/services/payment.service.ts +++ b/apps/api/src/services/payment.service.ts @@ -15,7 +15,7 @@ interface CreateOrderInput { notes?: Record; } -interface RazorpayOrderSuccess { +export interface RazorpayOrderSuccess { amount: number; amount_due: number; amount_paid: number; diff --git a/apps/api/src/utils/avatar-validator.ts b/apps/api/src/utils/avatar-validator.ts new file mode 100644 index 00000000..ddeee55f --- /dev/null +++ b/apps/api/src/utils/avatar-validator.ts @@ -0,0 +1,172 @@ +import { TRPCError } from "@trpc/server"; +import { isIP } from "net"; + +// Configuration +const ALLOWED_IMAGE_HOSTS = [ + "avatars.githubusercontent.com", + "lh3.googleusercontent.com", + "graph.facebook.com", + "pbs.twimg.com", + "cdn.discordapp.com", + "i.imgur.com", + "res.cloudinary.com", + "ik.imagekit.io", + "images.unsplash.com", + "ui-avatars.com", +]; + +const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB +const REQUEST_TIMEOUT_MS = 5000; // 5 seconds + +// Private IP ranges +const PRIVATE_IP_RANGES = [ + /^127\./, // 127.0.0.0/8 (localhost) + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^169\.254\./, // 169.254.0.0/16 (link-local) + /^::1$/, // IPv6 localhost + /^fe80:/, // IPv6 link-local + /^fc00:/, // IPv6 unique local + /^fd00:/, // IPv6 unique local +]; + +/** + * Validates if an IP address is private or localhost + */ +function isPrivateOrLocalIP(ip: string): boolean { + return PRIVATE_IP_RANGES.some((range) => range.test(ip)); +} + +/** + * Validates avatar URL with strict security checks + * @param avatarUrl - The URL to validate + * @throws TRPCError if validation fails + */ +export async function validateAvatarUrl(avatarUrl: string): Promise { + // Step 1: Basic URL format validation + let parsedUrl: URL; + try { + parsedUrl = new URL(avatarUrl); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid avatar URL format", + }); + } + + // Step 2: Require HTTPS scheme + if (parsedUrl.protocol !== "https:") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar URL must use HTTPS protocol", + }); + } + + // Step 3: Extract and validate hostname + const hostname = parsedUrl.hostname; + + // Step 4: Reject direct IP addresses + if (isIP(hostname)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar URL cannot be a direct IP address. Please use a trusted image hosting service.", + }); + } + + // Step 5: Check for localhost or private IP ranges + if (isPrivateOrLocalIP(hostname)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar URL cannot point to localhost or private network addresses", + }); + } + + // Step 6: Validate against allowlist of trusted hosts + const isAllowedHost = ALLOWED_IMAGE_HOSTS.some((allowedHost) => { + return hostname === allowedHost || hostname.endsWith(`.${allowedHost}`); + }); + + if (!isAllowedHost) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Avatar URL must be from a trusted image hosting service. Allowed hosts: ${ALLOWED_IMAGE_HOSTS.join(", ")}`, + }); + } + + // Step 7: Perform server-side HEAD request to validate the resource + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const response = await fetch(avatarUrl, { + method: "HEAD", + signal: controller.signal, + redirect: "error", + headers: { + "User-Agent": "OpenSox-Avatar-Validator/1.0", + }, + }); + + clearTimeout(timeoutId); + + // Check if request was successful + if (!response.ok) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Avatar URL is not accessible (HTTP ${response.status})`, + }); + } + + // Step 8: Validate Content-Type is an image + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.startsWith("image/")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Avatar URL must point to an image file. Received content-type: ${contentType || "unknown"}`, + }); + } + + // Step 9: Validate Content-Length is within limits + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const sizeBytes = parseInt(contentLength, 10); + if (sizeBytes > MAX_IMAGE_SIZE_BYTES) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Avatar image is too large. Maximum size: ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024}MB`, + }); + } + } + } catch (error) { + // Handle fetch errors + if (error instanceof TRPCError) { + throw error; + } + + if ((error as Error).name === "AbortError") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar URL validation timed out. The image may be too large or the server is unresponsive.", + }); + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Failed to validate avatar URL: ${(error as Error).message}`, + }); + } +} + +/** + * Zod custom refinement for avatar URL validation + * Use this with .refine() on a z.string().url() schema + */ +export async function avatarUrlRefinement(url: string): Promise { + try { + await validateAvatarUrl(url); + return true; + } catch (error) { + return false; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aa78dc6f..e73cdc6c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,7 +4,6 @@ // File Layout "rootDir": "./src", "outDir": "./dist", - // Environment Settings // See also https://aka.ms/tsconfig/module "module": "nodenext", @@ -13,16 +12,13 @@ // "lib": ["esnext"], // "types": ["node"], // and npm install -D @types/node - // Other Outputs "sourceMap": true, "declaration": true, "declarationMap": true, - // Stricter Typechecking Options "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - // Style Options // "noImplicitReturns": true, // "noImplicitOverride": true, @@ -30,7 +26,6 @@ // "noUnusedParameters": true, // "noFallthroughCasesInSwitch": true, // "noPropertyAccessFromIndexSignature": true, - // Recommended Options "strict": true, "jsx": "react-jsx", @@ -38,7 +33,7 @@ "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", - "skipLibCheck": true, + "skipLibCheck": true }, "include": [ "src/**/*" diff --git a/apps/web/next.config.js b/apps/web/next.config.js index dc6d8137..c021acb8 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -14,6 +14,18 @@ const nextConfig = { protocol: "https", hostname: "img.youtube.com", }, + { + protocol: "https", + hostname: "i.pravatar.cc", + }, + { + protocol: "https", + hostname: "picsum.photos", + }, + { + protocol: "https", + hostname: "standardcoldpressedoil.com", + }, ], }, experimental: { diff --git a/apps/web/package.json b/apps/web/package.json index c302d2b6..a59fec42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@heroicons/react": "^2.1.5", + "@hookform/resolvers": "^5.2.2", "@opensox/shared": "workspace:*", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-checkbox": "^1.1.2", @@ -17,8 +18,8 @@ "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", - "@trpc/client": "^11.6.0", - "@trpc/react-query": "^11.6.0", + "@trpc/client": "^11.7.2", + "@trpc/react-query": "^11.7.2", "@trpc/server": "^11.5.1", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", @@ -36,12 +37,14 @@ "posthog-js": "^1.203.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.68.0", "react-qr-code": "^2.0.18", "react-tweet": "^3.2.1", "sanitize-html": "^2.11.0", "superjson": "^2.2.5", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.9", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/apps/web/src/app/(main)/(landing)/layout.tsx b/apps/web/src/app/(main)/(landing)/layout.tsx index 1ca3e604..f50e0bbf 100644 --- a/apps/web/src/app/(main)/(landing)/layout.tsx +++ b/apps/web/src/app/(main)/(landing)/layout.tsx @@ -1,8 +1,10 @@ import React from 'react' +import Navbar from '@/components/landing-sections/navbar' const Layout = ({ children }: { children: React.ReactNode }) => { return (
+ {children}
) diff --git a/apps/web/src/app/(main)/(landing)/pricing/page.tsx b/apps/web/src/app/(main)/(landing)/pricing/page.tsx index 02892299..f925a710 100644 --- a/apps/web/src/app/(main)/(landing)/pricing/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pricing/page.tsx @@ -496,7 +496,7 @@ const SecondaryPricingCard = ({ callbackUrl }: { callbackUrl: string }) => {

(~ ₹4,410 INR)

- Discounted till 30 December + Discounted till 10 January
diff --git a/apps/web/src/app/(main)/dashboard/oss-programs/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/oss-programs/[slug]/page.tsx index 24371621..91f4f18e 100644 --- a/apps/web/src/app/(main)/dashboard/oss-programs/[slug]/page.tsx +++ b/apps/web/src/app/(main)/dashboard/oss-programs/[slug]/page.tsx @@ -79,15 +79,12 @@ export default async function ProgramPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - - // Lazy load only the program we need const program = await getProgramBySlug(slug); if (!program) { notFound(); } - // Pre-render all markdown sections at build time (server-side) const sectionsWithHtml = program.sections.map((section) => ({ ...section, contentHtml: renderMarkdown(section.bodyMarkdown), diff --git a/apps/web/src/app/(main)/dashboard/oss-programs/page.tsx b/apps/web/src/app/(main)/dashboard/oss-programs/page.tsx index 038cda78..383a05d8 100644 --- a/apps/web/src/app/(main)/dashboard/oss-programs/page.tsx +++ b/apps/web/src/app/(main)/dashboard/oss-programs/page.tsx @@ -4,7 +4,6 @@ import ProgramsList from "./ProgramsList"; export const revalidate = 3600; export default async function Page() { - // load programs and tags in parallel on server const [programs, tags] = await Promise.all([loadAllPrograms(), getAllTags()]); return ; diff --git a/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx b/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx index fae7f9ba..ec5af0b4 100644 --- a/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx @@ -4,6 +4,7 @@ import { useSubscription } from "@/hooks/useSubscription"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; export default function ProDashboardPage() { const { isPaidUser, isLoading } = useSubscription(); @@ -12,6 +13,19 @@ export default function ProDashboardPage() { const [error, setError] = useState(null); const [isJoining, setIsJoining] = useState(false); + // Check if user has already submitted a testimonial + const { data: testimonialData } = ( + trpc as any + ).testimonial.getMyTestimonial.useQuery(undefined, { + enabled: !!isPaidUser, + retry: false, + refetchOnWindowFocus: true, + refetchOnMount: true, + staleTime: 0, // Always fetch fresh data + }); + + const hasSubmittedTestimonial = !!testimonialData?.testimonial; + useEffect(() => { if (!isLoading && !isPaidUser) { router.push("/pricing"); @@ -80,7 +94,7 @@ export default function ProDashboardPage() { return; } - window.location.href = slackInviteUrl; + window.open(slackInviteUrl, "_blank", "noopener,noreferrer"); } catch (err) { console.error("Failed to join community:", err); setError("Failed to connect to server"); @@ -110,13 +124,23 @@ export default function ProDashboardPage() { {isPaidUser && (
- +
+ + {!hasSubmittedTestimonial && ( + + )} +
{error &&

{error}

}
)} diff --git a/apps/web/src/app/(main)/testimonials/page.tsx b/apps/web/src/app/(main)/testimonials/page.tsx new file mode 100644 index 00000000..6deedd11 --- /dev/null +++ b/apps/web/src/app/(main)/testimonials/page.tsx @@ -0,0 +1,245 @@ +"use client"; +import React, { useMemo } from "react"; +import Navbar from "@/components/landing-sections/navbar"; +import Footer from "@/components/landing-sections/footer"; +import Image from "next/image"; +import { Twitter, Linkedin, Instagram, Youtube } from "lucide-react"; + +import { trpc } from "@/lib/trpc"; +import { imageTestimonials } from "@/data/testimonials"; +import { Skeleton } from "@/components/ui/skeleton"; + +type TestimonialBase = { + id: string; + type: "text" | "image"; +}; + +type TextTestimonial = TestimonialBase & { + type: "text"; + content: string; + user: { + name: string; + username?: string; // e.g. @username + avatar: string; + socialLink?: string; + }; +}; + +type ImageTestimonial = TestimonialBase & { + type: "image"; + imageUrl: string; + alt: string; +}; + +type Testimonial = TextTestimonial | ImageTestimonial; + +// Helper function to get social icon based on URL +const getSocialIcon = (url: string) => { + try { + const hostname = new URL(url).hostname; + if (hostname.includes("twitter.com") || hostname.includes("x.com")) { + return ; + } + if (hostname.includes("linkedin.com")) { + return ; + } + if (hostname.includes("instagram.com")) { + return ; + } + if (hostname.includes("youtube.com") || hostname.includes("youtu.be")) { + return ; + } + return null; + } catch { + return null; + } +}; + +const TestimonialCard = ({ item }: { item: Testimonial }) => { + if (item.type === "image") { + return ( +
+
+ {item.alt} +
+
+ ); + } + + const socialIcon = item.user.socialLink + ? getSocialIcon(item.user.socialLink) + : null; + + return ( +
+
+
+ {item.user.name} +
+
+ + {item.user.name} + + {item.user.username && ( + + {item.user.username} + + )} +
+ {socialIcon && item.user.socialLink && ( + + {socialIcon} + + )} +
+

{item.content}

+
+ ); +}; + +const TestimonialsPage = () => { + // Fetch text testimonials from tRPC + const { data: textTestimonialsData, isLoading } = + trpc.testimonial.getAll.useQuery(); + + // Combine text testimonials from backend with image testimonials from data file + const allTestimonials = useMemo(() => { + const textTestimonials: TextTestimonial[] = ( + textTestimonialsData || [] + ).map( + (t: { + id: string; + content: string; + name: string; + avatar: string; + socialLink?: string; + }) => ({ + id: t.id, + type: "text" as const, + content: t.content, + user: { + name: t.name, + avatar: t.avatar, + socialLink: t.socialLink, + }, + }) + ); + + // Interleave text and image testimonials for better visual distribution + const combined: Testimonial[] = []; + let imageIndex = 0; + + // Add text testimonials and interleave images every 2-3 items + for (let i = 0; i < textTestimonials.length; i++) { + combined.push(textTestimonials[i]); + + // Add an image every 2-3 text testimonials + if ((i + 1) % 3 === 0 && imageIndex < imageTestimonials.length) { + combined.push(imageTestimonials[imageIndex]); + imageIndex++; + } + } + + // Add any remaining image testimonials at the end + while (imageIndex < imageTestimonials.length) { + combined.push(imageTestimonials[imageIndex]); + imageIndex++; + } + + return combined; + }, [textTestimonialsData]); + + return ( +
+ + +
+ {/* Header */} +
+

+ Loved by our Investors +

+

+ See what the people who believed in Opensox AI said about it. +

+
+ + {/* Loading State */} + {isLoading && ( +
+ {/* Text testimonial skeleton */} + {[...Array(8)].map((_, i) => ( +
+
+ +
+ + +
+
+
+ + + +
+
+ ))} + {/* Image testimonial skeleton */} + {[...Array(3)].map((_, i) => ( +
+ +
+ ))} +
+ )} + + {/* Masonry/Bento Grid */} + {!isLoading && allTestimonials.length > 0 && ( +
+ {allTestimonials.map((testimonial) => ( + + ))} +
+ )} + + {/* Empty State */} + {!isLoading && allTestimonials.length === 0 && ( +
+

+ No testimonials yet. Be the first to share your experience! +

+
+ )} +
+ +
+
+
+
+ ); +}; + +export default TestimonialsPage; diff --git a/apps/web/src/app/(main)/testimonials/submit/page.tsx b/apps/web/src/app/(main)/testimonials/submit/page.tsx new file mode 100644 index 00000000..e900f5ae --- /dev/null +++ b/apps/web/src/app/(main)/testimonials/submit/page.tsx @@ -0,0 +1,452 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { trpc } from "@/lib/trpc"; +import { useSubscription } from "@/hooks/useSubscription"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import Image from "next/image"; +import Navbar from "@/components/landing-sections/navbar"; +import Footer from "@/components/landing-sections/footer"; +import { Loader2, Link as LinkIcon, ArrowLeft } from "lucide-react"; +import { useSession } from "next-auth/react"; + +// Supported social platforms for validation +const SUPPORTED_PLATFORMS = [ + "twitter.com", + "x.com", + "linkedin.com", + "instagram.com", + "youtube.com", + "youtu.be", +]; + +const validateSocialLink = (url: string) => { + if (!url) return true; + try { + const parsedUrl = new URL(url); + return SUPPORTED_PLATFORMS.some( + (platform) => + parsedUrl.hostname === platform || + parsedUrl.hostname.endsWith("." + platform) + ); + } catch { + return false; + } +}; + +/** + * Schema for testimonial submission + */ +const formSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(40, "Name must be at most 40 characters"), + content: z + .string() + .min(10, "Testimonial must be at least 10 characters") + .max(1500, "Testimonial must be at most 1500 characters"), + socialLink: z + .string() + .refine( + (val) => !val || validateSocialLink(val), + "Only Twitter/X, LinkedIn, Instagram, and YouTube links are supported" + ) + .optional() + .or(z.literal("")), +}); + +type FormValues = z.infer; + +export default function SubmitTestimonialPage() { + const router = useRouter(); + const { data: session, status: sessionStatus } = useSession(); + const { isPaidUser, isLoading: isSubscriptionLoading } = useSubscription(); + + // Fetch existing testimonial data to check if already submitted + const { data, isLoading: isDataLoading } = ( + trpc as any + ).testimonial.getMyTestimonial.useQuery(undefined, { + enabled: !!isPaidUser, + retry: false, + refetchOnWindowFocus: true, + refetchOnMount: true, + staleTime: 0, // Always fetch fresh data + }); + + // Check if user already submitted a testimonial + const hasSubmittedTestimonial = !!data?.testimonial; + + const [error, setError] = useState(null); + + const submitMutation = (trpc as any).testimonial.submit.useMutation({ + onSuccess: async () => { + router.push("/testimonials"); + }, + onError: (error: any) => { + setError(error.message || "Error submitting testimonial"); + }, + }); + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + content: "", + socialLink: "", + }, + }); + + const nameValue = watch("name"); + const contentValue = watch("content"); + const socialLinkValue = watch("socialLink"); + + const displayAvatar = data?.testimonial?.avatar || session?.user?.image; + + // Effect to populate form with user session data (not existing testimonial since editing is disabled) + useEffect(() => { + if (session?.user && !hasSubmittedTestimonial) { + reset({ + name: session.user.name || "", + content: "", + socialLink: "", + }); + } + }, [session, reset, hasSubmittedTestimonial]); + + const onSubmit = (values: FormValues) => { + setError(null); + if (!displayAvatar) { + setError("Profile picture not found. Please log in again."); + return; + } + + submitMutation.mutate({ + name: values.name, + content: values.content, + avatar: displayAvatar, + socialLink: values.socialLink || undefined, + }); + }; + + // Loading State + if ( + sessionStatus === "loading" || + isSubscriptionLoading || + (isPaidUser && isDataLoading) + ) { + return ( +
+ +
+
+
+ + +
+ +
+ {/* Profile & Name Skeleton */} +
+ +
+ +
+ + +
+
+
+ + {/* Content Skeleton */} +
+ + + +
+ + {/* Social Link Skeleton */} +
+ + + +
+ + {/* Submit Button Skeleton */} + +
+
+
+
+
+
+
+ ); + } + + // Not Logged In State + if (sessionStatus === "unauthenticated") { + return ( +
+ +
+
+ 👤 +
+

Login Required

+

+ You need to be logged in to submit a testimonial. Please log in to + your account to continue. +

+
+ + +
+
+
+
+
+
+ ); + } + + // Access Denied State (Logged in but not paid) + if (!isPaidUser) { + return ( +
+ +
+
+ 🔒 +
+

Premium Feature

+

+ This feature is exclusively for premium users. Please upgrade your + plan to submit a testimonial. +

+
+ + +
+
+
+
+
+
+ ); + } + + return ( +
+ + +
+
+
+

+ What you think about me? +

+

+ Share your experience with the world. +

+
+ + + +
+ {/* Already Submitted State */} + {hasSubmittedTestimonial ? ( +
+
+ +
+

+ Testimonial Already Submitted +

+

+ Thank you! You have already submitted your testimonial. + Testimonials cannot be edited once submitted. +

+ +
+ ) : ( +
+ {/* Profile Picture and Display Name in Same Row */} +
+ +
+
+ {displayAvatar ? ( + Profile Picture { + (e.target as HTMLImageElement).src = + `https://i.pravatar.cc/150?u=error`; + }} + /> + ) : ( +
+ No Img +
+ )} +
+
+ +
+ {errors.name && ( +

+ {errors.name.message} +

+ )} +

+ {nameValue?.length || 0}/40 +

+
+
+
+
+ + {/* Content Field */} +
+ +