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 628886a5..d22dc57b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -39,4 +39,4 @@ "zeptomail": "^6.2.1", "zod": "^4.1.9" } -} +} \ No newline at end of file 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..ca2d59bc --- /dev/null +++ b/apps/api/src/routers/testimonial.ts @@ -0,0 +1,106 @@ +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) => { + // Fetch testimonials directly from database without caching + const testimonials = await ctx.db.prisma.testimonial.findMany({ + orderBy: { + createdAt: 'desc', + }, + }); + + return testimonials; + }), + + getMyTestimonial: protectedProcedure.query(async ({ ctx }: any) => { + const userId = ctx.user.id; + + // Check subscription + 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(1000, "Testimonial must be at most 1000 characters"), + avatar: z.string().url("Invalid avatar URL"), + socialLink: z.string().url("Invalid social link URL").refine((url) => { + const supportedPlatforms = [ + 'twitter.com', + 'x.com', + 'linkedin.com', + 'instagram.com', + 'youtube.com', + 'youtu.be', + ]; + try { + const parsedUrl = new URL(url); + return supportedPlatforms.some(platform => + parsedUrl.hostname === platform || + parsedUrl.hostname.endsWith('.' + platform) + ); + } catch { + return false; + } + }, "Only Twitter/X, LinkedIn, Instagram, and YouTube links are supported").optional().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", + }); + } + + + + // Check if testimonial already exists - prevent updates + 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/trpc.ts b/apps/api/src/trpc.ts index d9049676..c11611bc 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -2,12 +2,16 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { Context } from "./context.js"; import { verifyToken } from "./utils/auth.js"; +import type { User } from "@prisma/client"; + +// Type for context after authentication middleware +type ProtectedContext = Context & { user: User }; const t = initTRPC.context().create({ transformer: superjson, }); -const isAuthed = t.middleware(async ({ ctx, next }) => { +const isAuthed = t.middleware<{ ctx: ProtectedContext }>(async ({ ctx, next }) => { const authHeader = ctx.req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { @@ -25,7 +29,7 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { ctx: { ...ctx, user, - }, + } as ProtectedContext, }); } catch (error) { throw new TRPCError({ @@ -37,4 +41,4 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { export const router = t.router; export const publicProcedure = t.procedure; -export const protectedProcedure:any = t.procedure.use(isAuthed); +export const protectedProcedure = t.procedure.use(isAuthed); 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 977def00..697c56dd 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", @@ -40,4 +35,4 @@ "moduleDetection": "force", "skipLibCheck": true, } -} +} \ No newline at end of file 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 aaa18cbc..f9bb407d 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", @@ -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)/pitch/page.tsx b/apps/web/src/app/(main)/(landing)/pitch/page.tsx index 21b82b72..e3eb9333 100644 --- a/apps/web/src/app/(main)/(landing)/pitch/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pitch/page.tsx @@ -106,7 +106,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-4" > -

+

mission statement

@@ -134,7 +137,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

my goal

@@ -190,7 +196,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

the plan

@@ -258,13 +267,19 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-8" > -

+

philosophies i follow

{/* Philosophy #1 */}
-

+

#1 stay small. stay effective.

@@ -303,9 +318,9 @@ const Pitch = () => {

- if i go with this approach, i'll have to sacrifice those - fancy dreams of raising millions, being on the front - page of magazines, having millions of users, etc. + if i go with this approach, i'll have to sacrifice + those fancy dreams of raising millions, being on the + front page of magazines, having millions of users, etc.



but the good part is i'll be able to stay genuine @@ -341,7 +356,10 @@ const Pitch = () => { {/* Philosophy #2 */}

-

+

#2 go beyond what you promise.

@@ -374,7 +392,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

so how small?

@@ -425,7 +446,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

what existing investors said about me?

@@ -458,13 +482,19 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-8" > -

+

questions you may have

-

+

i'm not an absolute beginner, so how does subscribing to opensox.ai make sense to me?

@@ -485,7 +515,10 @@ const Pitch = () => {
-

+

will the quality of your service reduce as you grow?

@@ -496,7 +529,10 @@ const Pitch = () => {

-

+

how does opensox.ai pro help me?

    @@ -555,7 +591,10 @@ const Pitch = () => {
-

+

how much time does it take to get the results?

@@ -572,7 +611,10 @@ const Pitch = () => {

-

+

why should i trust you?

@@ -599,7 +641,10 @@ const Pitch = () => {

-

+

are there any alternatives to what you provide?

@@ -611,7 +656,10 @@ const Pitch = () => {

-

+

what's the difference between opensox pro and a course?

@@ -624,7 +672,10 @@ const Pitch = () => {
-

+

is it for an absolute beginner?

@@ -633,7 +684,10 @@ const Pitch = () => {

-

+

in what cases shouldn't i invest in opensox pro?

@@ -649,7 +703,7 @@ const Pitch = () => {

  • - you don't wanna do it fast + you don't wanna do it fast
  • @@ -663,7 +717,10 @@ const Pitch = () => {
  • -

    +

    are you the best in the market?

    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..95fe21ea 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"); @@ -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..81d52139 --- /dev/null +++ b/apps/web/src/app/(main)/testimonials/page.tsx @@ -0,0 +1,246 @@ +"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 Developers +

    +

    + See what the community is saying about how Opensox is changing their + open source workflow. +

    +
    + + {/* 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..5b82740b --- /dev/null +++ b/apps/web/src/app/(main)/testimonials/submit/page.tsx @@ -0,0 +1,451 @@ +"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; // Empty is allowed (optional) + try { + const parsedUrl = new URL(url); + return SUPPORTED_PLATFORMS.some((platform) => + parsedUrl.hostname.includes(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(1000, "Testimonial must be at most 1000 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 () => { + // Redirect to testimonials page + 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); // Clear previous errors + 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 community. +

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