diff --git a/_docs/front_end_best_practices b/_docs/front_end_best_practices index 0440af43..a867924e 160000 --- a/_docs/front_end_best_practices +++ b/_docs/front_end_best_practices @@ -1 +1 @@ -Subproject commit 0440af4316bb688457ef5c79dbb3b541e0cde794 +Subproject commit a867924e0e1519559b0dad7a992e74515c45dadf diff --git a/package.json b/package.json index de5805fa..b4a1442c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "check:env": "pnpm exec tsx tools/checks/check-analytics-env.ts", "check:chunk": "pnpm exec tsx tools/checks/verify-chunk-error-handler.ts", "check:meta": "pnpm exec tsx tools/checks/verify-meta-description.ts", + "validate:sitemap": "pnpm exec tsx tools/validate-sitemap.ts", "landing:build": "rm -rf dist && pnpm run check:env && next build && next export -o dist", "dev:vite": "pnpm exec tsx scripts/bundlers/ensure-preview-flag.ts vite:dev && pnpm exec vite --config tools/vite-preview/vite.config.ts", "build:vite": "pnpm exec tsx scripts/bundlers/ensure-preview-flag.ts vite:build && pnpm exec vite build --config tools/vite-preview/vite.config.ts", diff --git a/scripts b/scripts index f7883652..4c826673 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit f7883652d6cec3028df2cd4c5464a0569e439d1d +Subproject commit 4c826673a55899b54ff735986e581b1d02ba9c2f diff --git a/src/__tests__/api/rss/news-feeds.test.ts b/src/__tests__/api/rss/news-feeds.test.ts index d6cd9c18..1038392e 100644 --- a/src/__tests__/api/rss/news-feeds.test.ts +++ b/src/__tests__/api/rss/news-feeds.test.ts @@ -3,11 +3,11 @@ */ import { XMLParser } from "fast-xml-parser"; +import type { NextApiRequest, NextApiResponse } from "next"; import fetch, { Request as NodeFetchRequest, Response as NodeFetchResponse, } from "node-fetch"; -import type { NextApiRequest, NextApiResponse } from "next"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock Next.js API types @@ -45,7 +45,7 @@ const parser = new XMLParser({ // Helper to validate RSS 2.0 structure const validateRSS2 = (xml: string): boolean => { if (!xml || typeof xml !== "string") return false; - const hasRss = xml.includes(""); const hasTitle = xml.includes(""); const hasLink = xml.includes("<link>"); @@ -86,7 +86,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/inman.xml")).default; + const handler = (await import("../../../pages/api/rss/inman.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -108,7 +109,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/inman.xml")).default; + const handler = (await import("../../../pages/api/rss/inman.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -202,7 +204,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/connect-cre.xml")).default; + const handler = (await import("../../../pages/api/rss/connect-cre.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -224,7 +227,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/connect-cre.xml")).default; + const handler = (await import("../../../pages/api/rss/connect-cre.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -260,7 +264,9 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/world-property-journal.xml")).default; + const handler = ( + await import("../../../pages/api/rss/world-property-journal.xml") + ).default; const req = createMockRequest(); const res = createMockResponse(); @@ -282,7 +288,9 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/world-property-journal.xml")).default; + const handler = ( + await import("../../../pages/api/rss/world-property-journal.xml") + ).default; const req = createMockRequest(); const res = createMockResponse(); @@ -318,7 +326,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/first-tuesday.xml")).default; + const handler = (await import("../../../pages/api/rss/first-tuesday.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -340,7 +349,8 @@ describe("News RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/first-tuesday.xml")).default; + const handler = (await import("../../../pages/api/rss/first-tuesday.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -702,4 +712,3 @@ describe("News RSS Feeds", () => { }); }); }); - diff --git a/src/__tests__/api/rss/rss-feeds.test.ts b/src/__tests__/api/rss/rss-feeds.test.ts index 57df867b..e582a22f 100644 --- a/src/__tests__/api/rss/rss-feeds.test.ts +++ b/src/__tests__/api/rss/rss-feeds.test.ts @@ -3,11 +3,11 @@ */ import { XMLParser } from "fast-xml-parser"; +import type { NextApiRequest, NextApiResponse } from "next"; import fetch, { Request as NodeFetchRequest, Response as NodeFetchResponse, } from "node-fetch"; -import type { NextApiRequest, NextApiResponse } from "next"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock Next.js API types @@ -46,7 +46,7 @@ const parser = new XMLParser({ const validateRSS2 = (xml: string): boolean => { if (!xml || typeof xml !== "string") return false; // Basic XML structure check - if it has the required elements, it's valid - const hasRss = xml.includes("<rss") && xml.includes("version=\"2.0\""); + const hasRss = xml.includes("<rss") && xml.includes('version="2.0"'); const hasChannel = xml.includes("<channel>"); const hasTitle = xml.includes("<title>"); const hasLink = xml.includes("<link>"); @@ -154,7 +154,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/youtube.xml")).default; + const handler = (await import("../../../pages/api/rss/youtube.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -176,7 +177,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/youtube.xml")).default; + const handler = (await import("../../../pages/api/rss/youtube.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -207,7 +209,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/github.xml")).default; + const handler = (await import("../../../pages/api/rss/github.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -229,7 +232,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/github.xml")).default; + const handler = (await import("../../../pages/api/rss/github.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -294,7 +298,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/hybrid.xml")).default; + const handler = (await import("../../../pages/api/rss/hybrid.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -339,7 +344,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/hybrid.xml")).default; + const handler = (await import("../../../pages/api/rss/hybrid.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -382,7 +388,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/hybrid.xml")).default; + const handler = (await import("../../../pages/api/rss/hybrid.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -437,7 +444,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/hybrid.xml")).default; + const handler = (await import("../../../pages/api/rss/hybrid.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -488,7 +496,8 @@ describe("RSS Feeds", () => { } as Response); // @ts-expect-error - Dynamic import for test - const handler = (await import("../../../pages/api/rss/hybrid.xml")).default; + const handler = (await import("../../../pages/api/rss/hybrid.xml")) + .default; const req = createMockRequest(); const res = createMockResponse(); @@ -559,4 +568,3 @@ describe("RSS Feeds", () => { }); }); }); - diff --git a/src/app/__tests__/sitemap.test.ts b/src/app/__tests__/sitemap.test.ts new file mode 100644 index 00000000..b032a9f2 --- /dev/null +++ b/src/app/__tests__/sitemap.test.ts @@ -0,0 +1,312 @@ +import type { MetadataRoute } from "next"; +import { beforeAll, describe, expect, it } from "vitest"; + +import sitemap from "../sitemap"; + +describe("Sitemap Validation", () => { + const baseUrl = "https://dealscale.io"; + const validChangeFrequencies = [ + "always", + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "never", + ] as const; + + let entries: MetadataRoute.Sitemap; + + beforeAll(async () => { + entries = await sitemap(); + }); + + describe("Basic Structure", () => { + it("should return a non-empty sitemap array", () => { + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); + }); + + it("should have all entries with required fields", () => { + for (const entry of entries) { + expect(entry).toHaveProperty("url"); + expect(entry).toHaveProperty("lastModified"); + expect(entry).toHaveProperty("changeFrequency"); + expect(entry).toHaveProperty("priority"); + } + }); + }); + + describe("URL Validation", () => { + it("should only include URLs from the canonical domain (dealscale.io)", () => { + const externalUrls: string[] = []; + for (const entry of entries) { + if (entry.url) { + try { + const url = new URL(entry.url); + if (url.host !== "dealscale.io") { + externalUrls.push(entry.url); + } + } catch { + externalUrls.push(entry.url); + } + } + } + expect(externalUrls).toEqual([]); + }); + + it("should have valid, non-empty URLs", () => { + for (const entry of entries) { + expect(entry.url).toBeTruthy(); + expect(typeof entry.url).toBe("string"); + expect(entry.url.trim().length).toBeGreaterThan(0); + + // Verify URL format + expect(() => new URL(entry.url)).not.toThrow(); + } + }); + + it("should not have duplicate URLs", () => { + const urlSet = new Set<string>(); + const duplicates: string[] = []; + + for (const entry of entries) { + if (entry.url) { + if (urlSet.has(entry.url)) { + duplicates.push(entry.url); + } + urlSet.add(entry.url); + } + } + + expect(duplicates).toEqual([]); + }); + }); + + describe("Required Fields", () => { + it("should have lastModified as a Date object", () => { + for (const entry of entries) { + expect(entry.lastModified).toBeInstanceOf(Date); + } + }); + + it("should have valid changeFrequency values", () => { + for (const entry of entries) { + expect(entry.changeFrequency).toBeTruthy(); + expect(validChangeFrequencies).toContain(entry.changeFrequency); + } + }); + + it("should have priority between 0.0 and 1.0", () => { + for (const entry of entries) { + expect(typeof entry.priority).toBe("number"); + expect(entry.priority).toBeGreaterThanOrEqual(0); + expect(entry.priority).toBeLessThanOrEqual(1); + } + }); + }); + + describe("Content Coverage", () => { + it("should include the homepage", () => { + const homepage = entries.find((entry) => entry.url === baseUrl); + expect(homepage).toBeDefined(); + expect(homepage?.priority).toBeGreaterThan(0); + }); + + it("should include key static pages", () => { + const keyPages = [ + "/blogs", + "/about", + "/products", + "/features", + "/pricing", + "/partners", + "/case-studies", + ]; + + for (const page of keyPages) { + const entry = entries.find( + (entry) => entry.url === `${baseUrl}${page}`, + ); + expect(entry).toBeDefined(); + expect(entry?.priority).toBeGreaterThan(0); + } + }); + + it("should include RSS feed URLs", () => { + const rssFeeds = [ + "/rss.xml", + "/rss/youtube.xml", + "/rss/github.xml", + "/rss/hybrid.xml", + ]; + + for (const feed of rssFeeds) { + const entry = entries.find( + (entry) => entry.url === `${baseUrl}${feed}`, + ); + expect(entry).toBeDefined(); + } + }); + + it("should include video sitemap", () => { + const videoSitemap = entries.find( + (entry) => entry.url === `${baseUrl}/videos/sitemap.xml`, + ); + expect(videoSitemap).toBeDefined(); + }); + + it("should include blog posts with correct URL format", () => { + const blogPosts = entries.filter((entry) => + entry.url?.startsWith(`${baseUrl}/blogs/`), + ); + + // Should have at least some blog posts if they exist + // Format: https://dealscale.io/blogs/{post_id} + for (const post of blogPosts) { + expect(post.url).toMatch(/^https:\/\/dealscale\.io\/blogs\/[^/]+$/); + expect(post.priority).toBeGreaterThan(0); + } + }); + + it("should include case studies", () => { + const caseStudies = entries.filter((entry) => + entry.url?.startsWith(`${baseUrl}/case-studies/`), + ); + + // Should have at least some case studies if they exist + for (const study of caseStudies) { + expect(study.url).toMatch( + /^https:\/\/dealscale\.io\/case-studies\/[^/]+$/, + ); + expect(study.priority).toBeGreaterThan(0); + } + }); + + it("should include products", () => { + const products = entries.filter((entry) => + entry.url?.startsWith(`${baseUrl}/products/`), + ); + + // Should have at least some products if they exist + for (const product of products) { + expect(product.url).toMatch( + /^https:\/\/dealscale\.io\/products\/[^/]+$/, + ); + expect(product.priority).toBeGreaterThan(0); + } + }); + + it("should include features/services", () => { + const features = entries.filter((entry) => + entry.url?.startsWith(`${baseUrl}/features/`), + ); + + // Should have at least some features if they exist + for (const feature of features) { + expect(feature.url).toMatch( + /^https:\/\/dealscale\.io\/features\/[^/]+$/, + ); + expect(feature.priority).toBeGreaterThan(0); + } + }); + }); + + describe("Exclusions", () => { + it("should NOT include /careers (redirects externally)", () => { + const careers = entries.find( + (entry) => entry.url === `${baseUrl}/careers`, + ); + expect(careers).toBeUndefined(); + }); + + it("should NOT include external URLs (YouTube, GitHub direct links)", () => { + const externalDomains = ["youtube.com", "github.com", "beehiiv.com"]; + + for (const entry of entries) { + if (entry.url) { + try { + const url = new URL(entry.url); + for (const domain of externalDomains) { + expect(url.host).not.toContain(domain); + } + } catch { + // Invalid URL already caught in URL validation tests + } + } + } + }); + }); + + describe("Priority Distribution", () => { + it("should have homepage with high priority", () => { + const homepage = entries.find((entry) => entry.url === baseUrl); + expect(homepage?.priority).toBeGreaterThanOrEqual(0.8); + }); + + it("should have key pages with appropriate priorities", () => { + const highPriorityPages = ["/", "/pricing", "/features", "/blogs"]; + + for (const page of highPriorityPages) { + const entry = entries.find( + (entry) => entry.url === `${baseUrl}${page}`, + ); + if (entry) { + expect(entry.priority).toBeGreaterThanOrEqual(0.7); + } + } + }); + }); + + describe("Change Frequency Distribution", () => { + it("should have RSS feeds with hourly change frequency", () => { + const rssFeeds = entries.filter( + (entry) => + entry.url?.includes("/rss") || entry.url?.includes("/videos/sitemap"), + ); + + for (const feed of rssFeeds) { + expect(["hourly", "daily"]).toContain(feed.changeFrequency); + } + }); + + it("should have static pages with appropriate change frequencies", () => { + const staticPages = entries.filter((entry) => { + const url = entry.url?.replace(baseUrl, "") || ""; + return !url.includes("/") || url.split("/").length === 2; + }); + + for (const page of staticPages) { + expect(validChangeFrequencies).toContain(page.changeFrequency); + } + }); + }); + + describe("Edge Cases", () => { + it("should handle entries without optional fields gracefully", () => { + // All entries should have required fields (already tested above) + // This ensures no crashes on missing optional fields + for (const entry of entries) { + expect(entry.url).toBeDefined(); + expect(entry.lastModified).toBeDefined(); + expect(entry.changeFrequency).toBeDefined(); + expect(entry.priority).toBeDefined(); + } + }); + + it("should not have entries with empty or whitespace-only URLs", () => { + for (const entry of entries) { + if (entry.url) { + expect(entry.url.trim().length).toBeGreaterThan(0); + } + } + }); + + it("should have valid Date objects for lastModified", () => { + for (const entry of entries) { + expect(entry.lastModified).toBeInstanceOf(Date); + expect(Number.isNaN(entry.lastModified.getTime())).toBe(false); + } + }); + }); +}); diff --git a/src/app/affiliate/payment-setup/AffiliatePaymentSetupClient.tsx b/src/app/affiliate/payment-setup/AffiliatePaymentSetupClient.tsx index 3ffc13d3..c2d69d64 100644 --- a/src/app/affiliate/payment-setup/AffiliatePaymentSetupClient.tsx +++ b/src/app/affiliate/payment-setup/AffiliatePaymentSetupClient.tsx @@ -69,6 +69,3 @@ export default function AffiliatePaymentSetupClient() { content ); } - - - diff --git a/src/app/api/affiliates/payment-setup/route.ts b/src/app/api/affiliates/payment-setup/route.ts index 3a56c558..86fd5364 100644 --- a/src/app/api/affiliates/payment-setup/route.ts +++ b/src/app/api/affiliates/payment-setup/route.ts @@ -66,6 +66,3 @@ export async function POST(req: NextRequest) { return NextResponse.json(await response.json()); } - - - diff --git a/src/app/api/beehiiv/posts/route.ts b/src/app/api/beehiiv/posts/route.ts index f8dd67a2..a65fa71f 100644 --- a/src/app/api/beehiiv/posts/route.ts +++ b/src/app/api/beehiiv/posts/route.ts @@ -168,7 +168,9 @@ export async function GET(request: NextRequest) { (p as any)?.displayed_date, ); const isFuture = isFutureByDay(ts); - return s === "confirmed" && !hidden && (includeScheduled || !isFuture); + return ( + s === "confirmed" && !hidden && (includeScheduled || !isFuture) + ); }); const normalizedVisible = visiblePosts.map((post) => ensurePublishedAt(post as Record<string, unknown>), @@ -305,7 +307,9 @@ export async function GET(request: NextRequest) { (it as any)?.displayed_date, ); const isFuture = isFutureByDay(ts); - return s === "confirmed" && !hidden && (includeScheduled || !isFuture); + return ( + s === "confirmed" && !hidden && (includeScheduled || !isFuture) + ); }); filteredStream.push( ...visible.map((post) => diff --git a/src/app/api/campaigns/reactivate/route.ts b/src/app/api/campaigns/reactivate/route.ts index bc00414f..bf1af183 100644 --- a/src/app/api/campaigns/reactivate/route.ts +++ b/src/app/api/campaigns/reactivate/route.ts @@ -1,7 +1,7 @@ import { authOptions } from "@/lib/authOptions"; +import type { ContactData } from "@/utils/csvParser"; import { getServerSession } from "next-auth"; import { type NextRequest, NextResponse } from "next/server"; -import type { ContactData } from "@/utils/csvParser"; const DEALSCALE_API_BASE = process.env.DEALSCALE_API_BASE || "https://api.dealscale.io"; @@ -29,29 +29,33 @@ const TIME_PER_CONTACT_HOURS = 0.25; // Estimated time per contact in hours (15 export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions); - + // Allow unauthenticated requests in development mode for testing const isDevelopment = process.env.NODE_ENV === "development"; const hasAuth = session?.user && session?.dsTokens?.access_token; - + if (!isDevelopment && !hasAuth) { return NextResponse.json( - { + { error: "Unauthorized", - message: "Please sign in to activate campaigns" - }, - { status: 401 } + message: "Please sign in to activate campaigns", + }, + { status: 401 }, ); } // Use a mock token in development if no session - const accessToken = hasAuth - ? session.dsTokens.access_token + const accessToken = hasAuth + ? session.dsTokens.access_token : "dev-mock-token"; const body: ReactivateRequest = await req.json(); - if (!body.contacts || !Array.isArray(body.contacts) || body.contacts.length === 0) { + if ( + !body.contacts || + !Array.isArray(body.contacts) || + body.contacts.length === 0 + ) { return NextResponse.json( { error: "Missing or empty contacts array" }, { status: 400 }, @@ -215,10 +219,7 @@ async function batchActivateContacts( const batchPromises = batch.map(async (contact) => { try { // First, create or get contact ID - const contactId = await getOrCreateContactId( - contact, - accessToken, - ); + const contactId = await getOrCreateContactId(contact, accessToken); if (!contactId) { return { @@ -346,4 +347,3 @@ async function getOrCreateContactId( : `temp_${Date.now()}_${Math.random().toString(36).substring(7)}`; } } - diff --git a/src/app/api/closers/apply/route.ts b/src/app/api/closers/apply/route.ts index 491809cb..46f4a819 100644 --- a/src/app/api/closers/apply/route.ts +++ b/src/app/api/closers/apply/route.ts @@ -94,7 +94,3 @@ ${body.whyApply}`, ); } } - - - - diff --git a/src/app/awesome-real-estate-investing/AwesomeRealEstatePageClient.tsx b/src/app/awesome-real-estate-investing/AwesomeRealEstatePageClient.tsx index 316bb0ac..663ebdf4 100644 --- a/src/app/awesome-real-estate-investing/AwesomeRealEstatePageClient.tsx +++ b/src/app/awesome-real-estate-investing/AwesomeRealEstatePageClient.tsx @@ -8,7 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { ExternalLink, BookOpen, TrendingUp, Users } from "lucide-react"; +import { BookOpen, ExternalLink, TrendingUp, Users } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; diff --git a/src/app/awesome-real-estate-investing/page.tsx b/src/app/awesome-real-estate-investing/page.tsx index 399d6f8f..f05223d7 100644 --- a/src/app/awesome-real-estate-investing/page.tsx +++ b/src/app/awesome-real-estate-investing/page.tsx @@ -1,8 +1,5 @@ import { mapSeoMetaToMetadata } from "@/utils/seo/mapSeoMetaToMetadata"; -import { - SchemaInjector, - buildTechArticleSchema, -} from "@/utils/seo/schema"; +import { SchemaInjector, buildTechArticleSchema } from "@/utils/seo/schema"; import { getStaticSeo } from "@/utils/seo/staticSeo"; import type { Metadata } from "next"; import AwesomeRealEstatePageClient from "./AwesomeRealEstatePageClient"; @@ -19,10 +16,9 @@ export default async function AwesomeRealEstateInvestingPage() { headline: "Awesome Real Estate Investing – Curated List", url: seo.canonical || "/awesome-real-estate-investing", description: seo.description, - mainEntityOfPage: "https://deal-scale.github.io/awesome-real-estate-investing/", - sameAs: [ - "https://github.com/Deal-Scale/awesome-real-estate-investing", - ], + mainEntityOfPage: + "https://deal-scale.github.io/awesome-real-estate-investing/", + sameAs: ["https://github.com/Deal-Scale/awesome-real-estate-investing"], keywords: Array.isArray(seo.keywords) ? seo.keywords : undefined, image: seo.image, datePublished: "2025-01-01", @@ -36,4 +32,3 @@ export default async function AwesomeRealEstateInvestingPage() { </> ); } - diff --git a/src/app/blogs/[id]/page.tsx b/src/app/blogs/[id]/page.tsx new file mode 100644 index 00000000..cf1915c3 --- /dev/null +++ b/src/app/blogs/[id]/page.tsx @@ -0,0 +1,46 @@ +import { notFound, redirect } from "next/navigation"; + +import { getLatestBeehiivPosts } from "@/lib/beehiiv/getPosts"; + +interface BlogPostPageProps { + params: Promise<{ id: string }>; +} + +/** + * Dynamic route handler for individual blog posts + * Redirects to the actual Beehiiv URL to avoid broken links in sitemap + * + * This allows sitemap to use dealscale.io URLs (required by Google Search Console) + * while redirecting to Beehiiv content. The redirect passes SEO value to the destination URL. + */ +export default async function BlogPostPage(props: BlogPostPageProps) { + const { id } = await props.params; + + try { + // Fetch all posts to find the matching one + const posts = await getLatestBeehiivPosts({ + all: true, + includeScheduled: true, + }); + const post = posts.find((p) => p.id === id); + + // If post not found, return 404 + if (!post) { + notFound(); + } + + // If post exists but has no web_url, redirect to blogs index + const webUrl = post.web_url; + if (!webUrl || typeof webUrl !== "string") { + redirect("/blogs"); + } + + // Redirect to the Beehiiv URL + // Next.js redirect() uses 307 (temporary) but search engines will still follow it + redirect(webUrl); + } catch (error) { + // On error, redirect to blogs index + console.error(`[BlogPostPage] Error fetching post ${id}:`, error); + redirect("/blogs"); + } +} diff --git a/src/app/case-studies/[slug]/page.tsx b/src/app/case-studies/[slug]/page.tsx index 17fe42ce..3c72f219 100644 --- a/src/app/case-studies/[slug]/page.tsx +++ b/src/app/case-studies/[slug]/page.tsx @@ -6,8 +6,8 @@ import type { CaseStudy } from "@/types/case-study"; import { getTestBaseUrl } from "@/utils/env"; import { getSeoMetadataForCaseStudy } from "@/utils/seo/dynamic/case-studies"; import { - buildCaseStudyCreativeWorkSchema, SchemaInjector, + buildCaseStudyCreativeWorkSchema, } from "@/utils/seo/schema"; import CaseStudyPageClient from "./CaseStudyPageClient"; diff --git a/src/app/closers/apply/CloserApplication.tsx b/src/app/closers/apply/CloserApplication.tsx index 520f1410..ded085c2 100644 --- a/src/app/closers/apply/CloserApplication.tsx +++ b/src/app/closers/apply/CloserApplication.tsx @@ -16,7 +16,3 @@ export default function CloserApplication() { </main> ); } - - - - diff --git a/src/app/closers/apply/page.tsx b/src/app/closers/apply/page.tsx index 35860a75..b833a1a4 100644 --- a/src/app/closers/apply/page.tsx +++ b/src/app/closers/apply/page.tsx @@ -9,7 +9,3 @@ export async function generateMetadata(): Promise<Metadata> { } export default CloserApplication; - - - - diff --git a/src/app/features/page.tsx b/src/app/features/page.tsx index 550a7693..0be49007 100644 --- a/src/app/features/page.tsx +++ b/src/app/features/page.tsx @@ -1,13 +1,13 @@ -import type { Metadata } from "next"; import { dataModules } from "@/data/__generated__/modules"; import { featureTimeline } from "@/data/features/feature_timeline"; import { mapSeoMetaToMetadata } from "@/utils/seo/mapSeoMetaToMetadata"; import { + SchemaInjector, buildFAQPageSchema, buildRoadmapSchema, - SchemaInjector, } from "@/utils/seo/schema"; import { getStaticSeo } from "@/utils/seo/staticSeo"; +import type { Metadata } from "next"; import ServiceHomeClient from "./ServiceHomeClient"; // * Centralized SEO for /features using getStaticSeo helper diff --git a/src/app/layout.tsx b/src/app/layout.tsx index aed2d940..f3e967e2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { AppProviders } from "@/components/providers/AppProviders"; import type { AnalyticsConfig } from "@/lib/analytics/config"; import { getAnalyticsConfig } from "@/lib/analytics/config"; import { monoFont, sansFont } from "@/styles/fonts"; -import { buildKnowledgeGraphSchema, SchemaInjector } from "@/utils/seo/schema"; +import { SchemaInjector, buildKnowledgeGraphSchema } from "@/utils/seo/schema"; const KNOWLEDGE_GRAPH_SCHEMA = buildKnowledgeGraphSchema(); diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 845b6d2b..b7c5a017 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,9 +1,10 @@ +import type { MetadataRoute } from "next"; + import { getAllProducts } from "@/data/products/index"; import { getAllServices } from "@/data/service/services"; import { companyLogos } from "@/data/service/slug_data/trustedCompanies"; import { getLatestBeehiivPosts } from "@/lib/beehiiv/getPosts"; import { getAllCaseStudies } from "@/lib/caseStudies/case-studies"; -import type { BeehiivPost } from "@/types/behiiv"; import { getTestBaseUrl } from "@/utils/env"; import { getSeoMetadataForPost } from "@/utils/seo/dynamic/blog"; import { getSeoMetadataForCaseStudy } from "@/utils/seo/dynamic/case-studies"; @@ -11,7 +12,6 @@ import { getSeoMetadataForService } from "@/utils/seo/dynamic/services"; import { getSeoMetadataForProduct } from "@/utils/seo/product"; import type { SeoMeta } from "@/utils/seo/seo"; import { defaultSeo, staticSeoMeta } from "@/utils/seo/staticSeo"; -import type { MetadataRoute } from "next"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { // Resolve and normalize the canonical base URL @@ -33,6 +33,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { return url.replace(/\/+$/g, ""); }; const baseUrl = normalize(defaultSeo.canonical || getTestBaseUrl()); + + // Validate baseUrl is not empty + if (!baseUrl || baseUrl.trim().length === 0) { + throw new Error( + "baseUrl is empty or invalid. Check defaultSeo.canonical or NEXT_PUBLIC_SITE_URL.", + ); + } + // Static pages with SEO metadata for sitemap const staticPaths = Array.from(new Set(["/", ...Object.keys(staticSeoMeta)])); @@ -55,7 +63,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { dateModified: seo.dateModified, datePublished: seo.datePublished, priority: seo.priority ?? 0.8, - changeFrequency: seo.changeFrequency || "weekly", + changeFrequency: (seo.changeFrequency ?? + "weekly") as MetadataRoute.Sitemap[number]["changeFrequency"], }; return { @@ -66,7 +75,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { publishedTime: defaultValues.datePublished ? new Date(defaultValues.datePublished) : undefined, - changefreq: defaultValues.changeFrequency, + changeFrequency: + defaultValues.changeFrequency as MetadataRoute.Sitemap[number]["changeFrequency"], priority: defaultValues.priority, canonical: defaultValues.canonical, description: defaultValues.description, @@ -82,7 +92,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { return { url, lastModified: new Date(), - changefreq: "monthly" as const, + changeFrequency: "monthly" as const, priority: 0.5, canonical: url, title: `${partner.name} | Deal Scale Partner`, @@ -94,25 +104,39 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { }); // Dynamic blog posts with SEO - - const posts = await getLatestBeehiivPosts(); + // Fetch ALL posts including scheduled ones for sitemap + const posts = await getLatestBeehiivPosts({ + all: true, + includeScheduled: true, + }); const blogPosts = await Promise.all( posts.map(async (post) => { + // Construct dealscale.io URL format: /blogs/{post_id} + // Don't use post.web_url as it's likely a Beehiiv URL + const url = `${baseUrl}/blogs/${post.id}`; + + // Get SEO metadata for additional fields const seo = await getSeoMetadataForPost(post.id); + const changeFreq = seo?.changeFrequency || ("weekly" as const); + return { - url: seo?.canonical || post.web_url || "", + url, lastModified: typeof post.published_at === "string" || typeof post.published_at === "number" ? new Date(post.published_at) - : new Date(), - canonical: seo?.canonical, - title: seo?.title, + : typeof post.publish_date === "string" || + typeof post.publish_date === "number" + ? new Date(post.publish_date) + : new Date(), + canonical: url, + title: seo?.title || post.title, description: seo?.description, keywords: seo?.keywords, image: seo?.image, - type: "website", - changefreq: seo?.changeFrequency || "weekly", + type: "article" as const, + changeFrequency: + changeFreq as MetadataRoute.Sitemap[number]["changeFrequency"], priority: typeof seo?.priority === "number" ? seo.priority : 0.7, }; }), @@ -132,7 +156,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { description: seo?.description, keywords: seo?.keywords, image: seo?.image, - changefreq: seo?.changeFrequency || "weekly", + changeFrequency: (seo?.changeFrequency ?? + "weekly") as MetadataRoute.Sitemap[number]["changeFrequency"], priority: typeof seo?.priority === "number" ? seo.priority : 0.7, }; }), @@ -152,7 +177,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { keywords: seo?.keywords, image: seo?.image, type: "website", - changefreq: seo?.changeFrequency || "monthly", + changeFrequency: (seo?.changeFrequency ?? + "monthly") as MetadataRoute.Sitemap[number]["changeFrequency"], priority: typeof seo?.priority === "number" ? seo.priority : 0.6, }; }), @@ -168,14 +194,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { ); return { url: `${baseUrl}/features/${service.slugDetails.slug}`, - lastModified: service.slugDetails.lastModified, + lastModified: service.slugDetails.lastModified || new Date(), canonical: seo?.canonical, title: seo?.title, description: seo?.description, image: seo?.image, keywords: seo?.keywords, type: "website", - changefreq: seo?.changeFrequency || "weekly", + changeFrequency: (seo?.changeFrequency ?? + "weekly") as MetadataRoute.Sitemap[number]["changeFrequency"], priority: typeof seo?.priority === "number" ? seo.priority : 0.8, }; }), @@ -188,30 +215,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { // lastModified: service.lastModified, // })); - const externalEntries: MetadataRoute.Sitemap = [ - { - url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCphkra97DMNIAIvA1y8hZ-A", - changeFrequency: "daily", - priority: 0.4, - }, - { - url: "https://github.com/Deal-Scale/awesome-real-estate-investing", - lastModified: new Date(), - changeFrequency: "weekly", - priority: 0.6, - }, - { - url: "https://deal-scale.github.io/awesome-real-estate-investing/", - lastModified: new Date(), - changeFrequency: "weekly", - priority: 0.7, - }, - ]; - - const allowedExternalUrls = new Set( - externalEntries.map((entry) => entry.url), - ); - const canonicalHost = (() => { try { return new URL(baseUrl).host; @@ -235,46 +238,105 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> { { url: `${baseUrl}/rss.xml`, lastModified: new Date(), - changeFrequency: "hourly", + changeFrequency: "hourly" as const, priority: 0.6, }, { url: `${baseUrl}/rss/youtube.xml`, lastModified: new Date(), - changeFrequency: "hourly", + changeFrequency: "hourly" as const, priority: 0.6, }, { url: `${baseUrl}/rss/github.xml`, lastModified: new Date(), - changeFrequency: "hourly", + changeFrequency: "hourly" as const, priority: 0.6, }, { url: `${baseUrl}/rss/hybrid.xml`, lastModified: new Date(), - changeFrequency: "hourly", + changeFrequency: "hourly" as const, priority: 0.6, }, { url: `${baseUrl}/videos/sitemap.xml`, lastModified: new Date(), - changeFrequency: "daily", + changeFrequency: "daily" as const, priority: 0.6, }, - ]; + ].filter((entry) => { + // Ensure all supplemental entries have valid URLs + return !!(entry.url && entry.url.trim().length > 0); + }); - return [ + const allEntries = [ ...supplementalEntries, ...staticPages, ...partnerEntries, - ...blogPosts, + ...blogPosts.filter( + (entry): entry is NonNullable<typeof entry> => entry !== null, + ), ...caseStudyEntries, ...productEntries, ...serviceEntries, - ...externalEntries, - ].filter( - (entry) => - isOnCanonicalHost(entry.url) || allowedExternalUrls.has(entry.url), - ); + ]; + + const filteredEntries = allEntries + .filter((entry): entry is NonNullable<typeof entry> => { + // Filter out null/undefined entries + if (!entry) return false; + // Filter out entries without URLs or with empty/whitespace-only URLs + const url = entry.url?.trim(); + if (!url || url.length === 0) return false; + // Validate URL format + try { + new URL(url); + } catch { + return false; + } + // Filter to only canonical host URLs + return isOnCanonicalHost(url); + }) + .map((entry): MetadataRoute.Sitemap[number] => { + // Ensure all entries have required fields with defaults + const url = entry.url?.trim(); + if (!url || url.length === 0) { + // This should never happen due to filter above, but double-check + throw new Error( + `Entry missing URL after filtering: ${JSON.stringify(entry)}`, + ); + } + const validChangeFrequencies = [ + "always", + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "never", + ] as const; + const changeFreq = + entry.changeFrequency && + validChangeFrequencies.includes( + entry.changeFrequency as (typeof validChangeFrequencies)[number], + ) + ? (entry.changeFrequency as (typeof validChangeFrequencies)[number]) + : ("weekly" as const); + + return { + ...entry, + url, + lastModified: entry.lastModified || new Date(), + changeFrequency: changeFreq, + priority: entry.priority ?? 0.5, + }; + }) + // Final safety check - filter out any entries that somehow still have empty URLs + .filter((entry) => { + const url = entry.url?.trim(); + return !!(url && url.length > 0); + }); + + return filteredEntries; } diff --git a/src/components/affiliate/AffiliateApplicationSuccess.tsx b/src/components/affiliate/AffiliateApplicationSuccess.tsx index aa42566c..4ad880f0 100644 --- a/src/components/affiliate/AffiliateApplicationSuccess.tsx +++ b/src/components/affiliate/AffiliateApplicationSuccess.tsx @@ -51,6 +51,3 @@ export default function AffiliateApplicationSuccess() { </div> ); } - - - diff --git a/src/components/affiliate/AffiliatePendingApproval.tsx b/src/components/affiliate/AffiliatePendingApproval.tsx index 9bd34f44..c52399de 100644 --- a/src/components/affiliate/AffiliatePendingApproval.tsx +++ b/src/components/affiliate/AffiliatePendingApproval.tsx @@ -104,6 +104,3 @@ export default function AffiliatePendingApproval({ </div> ); } - - - diff --git a/src/components/bento/page/index.tsx b/src/components/bento/page/index.tsx index 70b5e969..85907a9a 100644 --- a/src/components/bento/page/index.tsx +++ b/src/components/bento/page/index.tsx @@ -38,7 +38,7 @@ const BentoPage: React.FC<BentoPageProps> = ({ title, subtitle, features }) => { </div> } > - <div className="relative z-10 flex transform-gpu flex-col gap-4 rounded-2xl bg-background/98 p-5 text-left text-foreground shadow-[0_15px_45px_-30px_rgba(14,165,233,0.35)] ring-1 ring-border/40 backdrop-blur-lg transition-all duration-300 ease-out will-change-opacity will-change-transform before:absolute before:inset-0 before:rounded-2xl before:bg-black/10 before:pointer-events-none group-hover:bg-background group-hover:ring-accent/50 dark:bg-background/90 dark:text-foreground"> + <div className="relative z-10 flex transform-gpu flex-col gap-4 rounded-2xl bg-background/98 p-5 text-left text-foreground shadow-[0_15px_45px_-30px_rgba(14,165,233,0.35)] ring-1 ring-border/40 backdrop-blur-lg transition-all duration-300 ease-out will-change-opacity will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-2xl before:bg-black/10 group-hover:bg-background group-hover:ring-accent/50 dark:bg-background/90 dark:text-foreground"> {feature.content} </div> </MagicBentoCard> diff --git a/src/components/blog/BlogSidebar.tsx b/src/components/blog/BlogSidebar.tsx index 261f3d82..7a777029 100644 --- a/src/components/blog/BlogSidebar.tsx +++ b/src/components/blog/BlogSidebar.tsx @@ -155,10 +155,10 @@ const BlogSidebar = ({ posts }: BlogSidebarProps) => { className="glass-card flex items-center justify-between rounded-xl p-6" > <div> - <h3 className="mb-1 font-semibold text-xl text-foreground"> + <h3 className="mb-1 font-semibold text-foreground text-xl"> RSS Feed </h3> - <p className="text-sm text-muted-foreground"> + <p className="text-muted-foreground text-sm"> Subscribe to our RSS feed </p> </div> diff --git a/src/components/blog/RecentPostsSection.tsx b/src/components/blog/RecentPostsSection.tsx index fc4d3c72..71f936b0 100644 --- a/src/components/blog/RecentPostsSection.tsx +++ b/src/components/blog/RecentPostsSection.tsx @@ -23,7 +23,7 @@ export function RecentPostsSection({ title, posts }: RecentPostsSectionProps) { > <h3 id={`${title.replace(/\s+/g, "-").toLowerCase()}-heading`} - className="mb-5 font-semibold text-lg text-foreground" + className="mb-5 font-semibold text-foreground text-lg" > {title} </h3> @@ -55,7 +55,7 @@ export function RecentPostsSection({ title, posts }: RecentPostsSectionProps) { height={450} /> <div className="flex flex-col"> - <h4 className="font-semibold text-sm text-foreground transition-colors group-hover:text-primary"> + <h4 className="font-semibold text-foreground text-sm transition-colors group-hover:text-primary"> {post.title} </h4> <p className="mt-0.5 text-muted-foreground text-xs"> diff --git a/src/components/case-studies/CaseStudyDetailHeader.tsx b/src/components/case-studies/CaseStudyDetailHeader.tsx index eb50cbe2..748b10e2 100644 --- a/src/components/case-studies/CaseStudyDetailHeader.tsx +++ b/src/components/case-studies/CaseStudyDetailHeader.tsx @@ -1,8 +1,3 @@ -import { motion } from "framer-motion"; -import { ChevronLeft, Eye, Share2 } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { v4 as uuidv4 } from "uuid"; import { SocialShare } from "@/components/common/social/share/SocialShare"; import { Button } from "@/components/ui/button"; import { @@ -12,6 +7,11 @@ import { } from "@/components/ui/popover"; import { useHasMounted } from "@/hooks/useHasMounted"; import type { CaseStudy } from "@/types/case-study"; +import { motion } from "framer-motion"; +import { ChevronLeft, Eye, Share2 } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { v4 as uuidv4 } from "uuid"; interface CaseStudyDetailHeaderProps { caseStudy: CaseStudy; diff --git a/src/components/case-studies/ProblemSolutions.tsx b/src/components/case-studies/ProblemSolutions.tsx index 072c5533..7df40670 100644 --- a/src/components/case-studies/ProblemSolutions.tsx +++ b/src/components/case-studies/ProblemSolutions.tsx @@ -1,5 +1,5 @@ -import { motion } from "framer-motion"; import type { CaseStudy } from "@/types/case-study"; +import { motion } from "framer-motion"; interface ProblemSolutionsProps { caseStudy: CaseStudy; diff --git a/src/components/closers/BecomeACloserCard.tsx b/src/components/closers/BecomeACloserCard.tsx index 7a212ae8..cba52af2 100644 --- a/src/components/closers/BecomeACloserCard.tsx +++ b/src/components/closers/BecomeACloserCard.tsx @@ -26,8 +26,10 @@ const BecomeACloserCard = ({ whileHover={{ scale: 1.02 }} transition={{ type: "spring", stiffness: 300, damping: 20 }} className={cn( - "relative flex min-h-[280px] w-full cursor-pointer flex-col overflow-hidden rounded-xl border-2 border-dashed border-blue-400 transition-all hover:border-blue-300 hover:shadow-2xl dark:border-blue-500", - imageUrl ? "border-blue-300" : "bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 dark:from-blue-900/70 dark:via-indigo-900/70 dark:to-purple-900/70", + "relative flex min-h-[280px] w-full cursor-pointer flex-col overflow-hidden rounded-xl border-2 border-blue-400 border-dashed transition-all hover:border-blue-300 hover:shadow-2xl dark:border-blue-500", + imageUrl + ? "border-blue-300" + : "bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 dark:from-blue-900/70 dark:via-indigo-900/70 dark:to-purple-900/70", className, )} onClick={onClick} @@ -56,22 +58,23 @@ const BecomeACloserCard = ({ )} <div className="relative z-10 flex min-h-[280px] w-full flex-col items-center justify-center p-8 text-center"> <div className="mb-4 flex gap-2"> - <span className="rounded-full bg-white/20 px-3 py-1 text-white text-xs font-semibold backdrop-blur-sm"> + <span className="rounded-full bg-white/20 px-3 py-1 font-semibold text-white text-xs backdrop-blur-sm"> Browse All </span> - <span className="rounded-full bg-white/20 px-3 py-1 text-white text-xs font-semibold backdrop-blur-sm"> + <span className="rounded-full bg-white/20 px-3 py-1 font-semibold text-white text-xs backdrop-blur-sm"> Apply to Join </span> </div> - <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/20 text-white text-5xl backdrop-blur-sm"> + <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/20 text-5xl text-white backdrop-blur-sm"> + </div> - <h3 className="mb-2 font-bold text-white text-2xl">{title}</h3> - <p className="max-w-md text-white/90 text-sm leading-relaxed">{subtitle}</p> + <h3 className="mb-2 font-bold text-2xl text-white">{title}</h3> + <p className="max-w-md text-sm text-white/90 leading-relaxed"> + {subtitle} + </p> </div> </motion.div> ); }; export default BecomeACloserCard; - diff --git a/src/components/closers/ClosersMarketplaceModal.tsx b/src/components/closers/ClosersMarketplaceModal.tsx index 8c6172dd..29679e00 100644 --- a/src/components/closers/ClosersMarketplaceModal.tsx +++ b/src/components/closers/ClosersMarketplaceModal.tsx @@ -1,13 +1,24 @@ "use client"; -import { mockClosers, type CloserProfile } from "@/data/closers/mockClosers"; -import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { type CloserProfile, mockClosers } from "@/data/closers/mockClosers"; import { usePagination } from "@/hooks/use-pagination"; -import { motion, AnimatePresence } from "framer-motion"; -import { X, Star, MapPin, CheckCircle2, ChevronLeft, ChevronRight, Percent, MessageSquare, UserPlus, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { + CheckCircle2, + ChevronLeft, + ChevronRight, + Loader2, + MapPin, + MessageSquare, + Percent, + Star, + UserPlus, + X, +} from "lucide-react"; import Image from "next/image"; import React, { useState, useMemo, useCallback } from "react"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import toast from "react-hot-toast"; interface ClosersMarketplaceModalProps { @@ -60,7 +71,10 @@ const ClosersMarketplaceModal = ({ // Debug: Log when modal opens and closers count React.useEffect(() => { if (isOpen) { - console.log("[ClosersMarketplaceModal] Modal opened, closers count:", mockClosers.length); + console.log( + "[ClosersMarketplaceModal] Modal opened, closers count:", + mockClosers.length, + ); } }, [isOpen]); @@ -72,28 +86,34 @@ const ClosersMarketplaceModal = ({ }, [isOpen, viewMode, setPage]); // Handle hire closer - const handleHireCloser = useCallback((closer: CloserProfile, event?: React.MouseEvent) => { - event?.stopPropagation(); // Prevent card selection - setIsLoading(true); - // TODO: Open booking/hiring modal or redirect to booking page - setTimeout(() => { - toast.success(`Hiring ${closer.name}...`); - setIsLoading(false); - console.log("Hire closer:", closer.id); - }, 300); - }, []); + const handleHireCloser = useCallback( + (closer: CloserProfile, event?: React.MouseEvent) => { + event?.stopPropagation(); // Prevent card selection + setIsLoading(true); + // TODO: Open booking/hiring modal or redirect to booking page + setTimeout(() => { + toast.success(`Hiring ${closer.name}...`); + setIsLoading(false); + console.log("Hire closer:", closer.id); + }, 300); + }, + [], + ); // Handle contact closer - const handleContactCloser = useCallback((closer: CloserProfile, event?: React.MouseEvent) => { - event?.stopPropagation(); // Prevent card selection - setIsLoading(true); - // TODO: Open messaging modal or redirect to messaging page - setTimeout(() => { - toast.success(`Messaging ${closer.name}...`); - setIsLoading(false); - console.log("Contact closer:", closer.id); - }, 300); - }, []); + const handleContactCloser = useCallback( + (closer: CloserProfile, event?: React.MouseEvent) => { + event?.stopPropagation(); // Prevent card selection + setIsLoading(true); + // TODO: Open messaging modal or redirect to messaging page + setTimeout(() => { + toast.success(`Messaging ${closer.name}...`); + setIsLoading(false); + console.log("Contact closer:", closer.id); + }, 300); + }, + [], + ); const handleApplyAsCloser = () => { if (onApplyClick) { @@ -124,11 +144,11 @@ const ClosersMarketplaceModal = ({ exit={{ opacity: 0, scale: 0.95, y: 20 }} className="fixed inset-0 z-50 flex items-center justify-center p-4" > - <div className="relative w-full max-w-6xl max-h-[90vh] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"> + <div className="relative max-h-[90vh] w-full max-w-6xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"> {/* Header */} - <div className="flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-4 dark:border-slate-800"> + <div className="flex items-center justify-between border-slate-200 border-b bg-gradient-to-r from-blue-600 to-indigo-600 px-6 py-4 dark:border-slate-800"> <div> - <h2 className="font-bold text-white text-2xl"> + <h2 className="font-bold text-2xl text-white"> Remote Closers Marketplace </h2> <p className="mt-1 text-blue-100 text-sm"> @@ -146,8 +166,8 @@ const ClosersMarketplaceModal = ({ {/* Content */} <div - className="overflow-y-auto p-6 scroll-smooth" - style={{ + className="overflow-y-auto scroll-smooth p-6" + style={{ maxHeight: "calc(90vh - 140px)", scrollBehavior: "smooth", scrollPaddingTop: "24px", @@ -155,7 +175,7 @@ const ClosersMarketplaceModal = ({ > {/* Monetize Card */} <div - className="mb-6 flex min-h-[180px] w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-blue-300 bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 p-6 text-center transition-all hover:border-blue-400 hover:shadow-lg dark:border-blue-600 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20" + className="mb-6 flex min-h-[180px] w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-blue-300 border-dashed bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 p-6 text-center transition-all hover:border-blue-400 hover:shadow-lg dark:border-blue-600 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20" onClick={handleApplyAsCloser} role="button" tabIndex={0} @@ -167,17 +187,17 @@ const ClosersMarketplaceModal = ({ }} > <div className="mb-3 flex gap-2"> - <span className="rounded-full bg-blue-600 px-3 py-1 text-white text-xs font-semibold"> + <span className="rounded-full bg-blue-600 px-3 py-1 font-semibold text-white text-xs"> Browse {mockClosers.length} Closers </span> - <span className="rounded-full bg-indigo-600 px-3 py-1 text-white text-xs font-semibold"> + <span className="rounded-full bg-indigo-600 px-3 py-1 font-semibold text-white text-xs"> Apply to Join </span> </div> - <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-600 text-white text-3xl"> + <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-600 text-3xl text-white"> + </div> - <h3 className="mb-1 font-bold text-slate-900 text-lg dark:text-white"> + <h3 className="mb-1 font-bold text-lg text-slate-900 dark:text-white"> Apply to Become a Closer </h3> <p className="max-w-md text-slate-600 text-xs dark:text-slate-300"> @@ -188,222 +208,242 @@ const ClosersMarketplaceModal = ({ {/* Closers Grid */} <div className="mb-4"> <div className="mb-4 flex items-center justify-between"> - <h3 className="font-semibold text-slate-900 text-lg dark:text-white"> - {viewMode === "featured" + <h3 className="font-semibold text-lg text-slate-900 dark:text-white"> + {viewMode === "featured" ? `Featured Closers (${featuredClosers.length})` : `All Closers (${mockClosers.length})`} </h3> - <Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "featured" | "all")}> + <Tabs + value={viewMode} + onValueChange={(v) => + setViewMode(v as "featured" | "all") + } + > <TabsList className="h-8"> - <TabsTrigger value="featured" className="text-xs px-3 py-1"> + <TabsTrigger + value="featured" + className="px-3 py-1 text-xs" + > Featured </TabsTrigger> - <TabsTrigger value="all" className="text-xs px-3 py-1"> + <TabsTrigger value="all" className="px-3 py-1 text-xs"> All </TabsTrigger> </TabsList> </Tabs> </div> {!displayedClosers || displayedClosers.length === 0 ? ( - <p className="rounded-lg bg-yellow-50 p-4 text-yellow-800 text-center text-sm dark:bg-yellow-900/20 dark:text-yellow-400"> - ⚠️ No closers available at this time. Check console for errors. + <p className="rounded-lg bg-yellow-50 p-4 text-center text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"> + ⚠️ No closers available at this time. Check console for + errors. </p> ) : ( <> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {paginatedClosers.map((closer) => ( - <motion.div - key={closer.id} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.1 }} - className={cn( - "group relative overflow-hidden rounded-xl border border-slate-200 bg-white p-6 shadow-sm transition-all hover:border-blue-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-800", - selectedCloser === closer.id && - "border-blue-500 ring-2 ring-blue-500", - )} - > - {/* Closer Image */} - <div className="mb-4 flex items-center gap-4"> - <div className="relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"> - <Image - src={closer.image} - alt={closer.name} - fill - className="object-cover" - sizes="64px" - onError={(e) => { - console.error("[ClosersMarketplaceModal] Image failed to load:", closer.image); - (e.target as HTMLImageElement).src = "https://via.placeholder.com/64x64?text=" + closer.name.charAt(0); - }} - /> - </div> - <div className="flex-1"> - <h4 className="font-semibold text-slate-900 text-base dark:text-white"> - {closer.name} - </h4> - <p className="text-slate-600 text-xs dark:text-slate-400"> - {closer.title} - </p> - </div> - </div> - - {/* Rating & Stats */} - <div className="mb-3 flex items-center gap-2"> - <div className="flex items-center gap-1"> - <Star className="h-4 w-4 fill-yellow-400 text-yellow-400" /> - <span className="font-semibold text-slate-900 text-sm dark:text-white"> - {closer.rating} - </span> - </div> - <span className="text-slate-500 text-xs dark:text-slate-400"> - ({closer.reviews} reviews) - </span> - <span className="text-slate-300 dark:text-slate-600"> - • - </span> - <span className="text-slate-500 text-xs dark:text-slate-400"> - {closer.dealsClosed} deals closed - </span> - </div> + <motion.div + key={closer.id} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.1 }} + className={cn( + "group relative overflow-hidden rounded-xl border border-slate-200 bg-white p-6 shadow-sm transition-all hover:border-blue-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-800", + selectedCloser === closer.id && + "border-blue-500 ring-2 ring-blue-500", + )} + > + {/* Closer Image */} + <div className="mb-4 flex items-center gap-4"> + <div className="relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"> + <Image + src={closer.image} + alt={closer.name} + fill + className="object-cover" + sizes="64px" + onError={(e) => { + console.error( + "[ClosersMarketplaceModal] Image failed to load:", + closer.image, + ); + (e.target as HTMLImageElement).src = + "https://via.placeholder.com/64x64?text=" + + closer.name.charAt(0); + }} + /> + </div> + <div className="flex-1"> + <h4 className="font-semibold text-base text-slate-900 dark:text-white"> + {closer.name} + </h4> + <p className="text-slate-600 text-xs dark:text-slate-400"> + {closer.title} + </p> + </div> + </div> - {/* Location */} - <div className="mb-3 flex items-center gap-1.5 text-slate-600 text-xs dark:text-slate-400"> - <MapPin className="h-3.5 w-3.5" /> - {closer.location} - </div> + {/* Rating & Stats */} + <div className="mb-3 flex items-center gap-2"> + <div className="flex items-center gap-1"> + <Star className="h-4 w-4 fill-yellow-400 text-yellow-400" /> + <span className="font-semibold text-slate-900 text-sm dark:text-white"> + {closer.rating} + </span> + </div> + <span className="text-slate-500 text-xs dark:text-slate-400"> + ({closer.reviews} reviews) + </span> + <span className="text-slate-300 dark:text-slate-600"> + • + </span> + <span className="text-slate-500 text-xs dark:text-slate-400"> + {closer.dealsClosed} deals closed + </span> + </div> - {/* Bio */} - <p className="mb-3 line-clamp-2 text-slate-600 text-xs dark:text-slate-400"> - {closer.bio} - </p> + {/* Location */} + <div className="mb-3 flex items-center gap-1.5 text-slate-600 text-xs dark:text-slate-400"> + <MapPin className="h-3.5 w-3.5" /> + {closer.location} + </div> - {/* Specialties */} - <div className="mb-3 flex flex-wrap gap-1.5"> - {closer.specialties.slice(0, 2).map((specialty) => ( - <span - key={specialty} - className="rounded-full bg-blue-100 px-2 py-0.5 text-blue-700 text-xs dark:bg-blue-900/30 dark:text-blue-300" - > - {specialty} - </span> - ))} - {closer.specialties.length > 2 && ( - <span className="rounded-full bg-slate-100 px-2 py-0.5 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-400"> - +{closer.specialties.length - 2} - </span> - )} - </div> + {/* Bio */} + <p className="mb-3 line-clamp-2 text-slate-600 text-xs dark:text-slate-400"> + {closer.bio} + </p> - {/* Rate & Commission */} - <div className="mb-3 space-y-2"> - <div className="flex items-center justify-between"> - <span className="font-semibold text-slate-900 text-base dark:text-white"> - ${closer.hourlyRate}/hr - </span> - </div> - - {/* Commission Percentage */} - {closer.commissionPercentage && ( - <div className="flex items-center gap-1.5 rounded-md bg-blue-50 px-2 py-1 text-blue-700 text-xs dark:bg-blue-900/20 dark:text-blue-300"> - <Percent className="h-3 w-3" /> - <span className="font-medium"> - {closer.commissionPercentage}% commission on deals - </span> + {/* Specialties */} + <div className="mb-3 flex flex-wrap gap-1.5"> + {closer.specialties + .slice(0, 2) + .map((specialty) => ( + <span + key={specialty} + className="rounded-full bg-blue-100 px-2 py-0.5 text-blue-700 text-xs dark:bg-blue-900/30 dark:text-blue-300" + > + {specialty} + </span> + ))} + {closer.specialties.length > 2 && ( + <span className="rounded-full bg-slate-100 px-2 py-0.5 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-400"> + +{closer.specialties.length - 2} + </span> + )} </div> - )} - {/* SaaS Split */} - {closer.saasSplit && ( - <div className="rounded-md bg-slate-100 p-2 dark:bg-slate-700/50"> - <div className="flex items-center justify-between text-slate-700 text-xs dark:text-slate-300"> - <span>Platform Fee:</span> - <span className="font-semibold">{closer.saasSplit.platformFee}%</span> - </div> - <div className="mt-1 flex items-center justify-between text-slate-700 text-xs dark:text-slate-300"> - <span>You Keep:</span> - <span className="font-semibold text-green-600 dark:text-green-400"> - {closer.saasSplit.closerFee}% + {/* Rate & Commission */} + <div className="mb-3 space-y-2"> + <div className="flex items-center justify-between"> + <span className="font-semibold text-base text-slate-900 dark:text-white"> + ${closer.hourlyRate}/hr </span> </div> + + {/* Commission Percentage */} + {closer.commissionPercentage && ( + <div className="flex items-center gap-1.5 rounded-md bg-blue-50 px-2 py-1 text-blue-700 text-xs dark:bg-blue-900/20 dark:text-blue-300"> + <Percent className="h-3 w-3" /> + <span className="font-medium"> + {closer.commissionPercentage}% commission on + deals + </span> + </div> + )} + + {/* SaaS Split */} + {closer.saasSplit && ( + <div className="rounded-md bg-slate-100 p-2 dark:bg-slate-700/50"> + <div className="flex items-center justify-between text-slate-700 text-xs dark:text-slate-300"> + <span>Platform Fee:</span> + <span className="font-semibold"> + {closer.saasSplit.platformFee}% + </span> + </div> + <div className="mt-1 flex items-center justify-between text-slate-700 text-xs dark:text-slate-300"> + <span>You Keep:</span> + <span className="font-semibold text-green-600 dark:text-green-400"> + {closer.saasSplit.closerFee}% + </span> + </div> + </div> + )} </div> - )} - </div> - {/* Action Buttons */} - <div className="mt-4 flex gap-2"> + {/* Action Buttons */} + <div className="mt-4 flex gap-2"> + <button + type="button" + onClick={(e) => handleContactCloser(closer, e)} + disabled={isLoading} + className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 font-medium text-slate-700 text-xs transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700" + > + {isLoading ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <MessageSquare className="h-3 w-3" /> + )} + <span>Contact</span> + </button> + <button + type="button" + onClick={(e) => handleHireCloser(closer, e)} + disabled={isLoading} + className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 py-2 font-medium text-white text-xs transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-700 dark:hover:bg-blue-800" + > + {isLoading ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <UserPlus className="h-3 w-3" /> + )} + <span>Hire</span> + </button> + </div> + </motion.div> + ))} + </div> + + {/* Pagination Controls */} + {canShowPagination && ( + <div className="mt-6 flex items-center justify-center gap-4"> <button - type="button" - onClick={(e) => handleContactCloser(closer, e)} - disabled={isLoading} - className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 font-medium text-slate-700 text-xs transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700" - > - {isLoading ? ( - <Loader2 className="h-3 w-3 animate-spin" /> - ) : ( - <MessageSquare className="h-3 w-3" /> + onClick={prevPage} + disabled={page === 1} + className={cn( + "flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 text-sm transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700", + page === 1 && "cursor-not-allowed opacity-50", )} - <span>Contact</span> + aria-label="Previous page" + > + <ChevronLeft className="h-4 w-4" /> + Previous </button> + <div className="flex items-center gap-2"> + <span className="text-slate-600 text-sm dark:text-slate-400"> + Page {page} of {totalPages} + </span> + </div> <button - type="button" - onClick={(e) => handleHireCloser(closer, e)} - disabled={isLoading} - className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 py-2 font-medium text-white text-xs transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-700 dark:hover:bg-blue-800" - > - {isLoading ? ( - <Loader2 className="h-3 w-3 animate-spin" /> - ) : ( - <UserPlus className="h-3 w-3" /> + onClick={nextPage} + disabled={page === totalPages} + className={cn( + "flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 text-sm transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700", + page === totalPages && + "cursor-not-allowed opacity-50", )} - <span>Hire</span> + aria-label="Next page" + > + Next + <ChevronRight className="h-4 w-4" /> </button> </div> - </motion.div> - ))} - </div> - - {/* Pagination Controls */} - {canShowPagination && ( - <div className="mt-6 flex items-center justify-center gap-4"> - <button - onClick={prevPage} - disabled={page === 1} - className={cn( - "flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 text-sm transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700", - page === 1 && "opacity-50 cursor-not-allowed" - )} - aria-label="Previous page" - > - <ChevronLeft className="h-4 w-4" /> - Previous - </button> - <div className="flex items-center gap-2"> - <span className="text-slate-600 text-sm dark:text-slate-400"> - Page {page} of {totalPages} - </span> - </div> - <button - onClick={nextPage} - disabled={page === totalPages} - className={cn( - "flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 text-sm transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700", - page === totalPages && "opacity-50 cursor-not-allowed" - )} - aria-label="Next page" - > - Next - <ChevronRight className="h-4 w-4" /> - </button> - </div> - )} - </> + )} + </> )} </div> </div> {/* Footer */} - <div className="border-t border-slate-200 bg-slate-50 px-6 py-4 dark:border-slate-800 dark:bg-slate-900/50"> + <div className="border-slate-200 border-t bg-slate-50 px-6 py-4 dark:border-slate-800 dark:bg-slate-900/50"> <div className="flex items-center justify-between"> <p className="text-slate-600 text-sm dark:text-slate-400"> {selectedCloser @@ -423,7 +463,7 @@ const ClosersMarketplaceModal = ({ // Handle booking logic here console.log("Booking closer:", selectedCloser); }} - className="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white text-sm transition-colors hover:bg-blue-700" + className="rounded-lg bg-blue-600 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-blue-700" > Book Closer </button> @@ -440,4 +480,3 @@ const ClosersMarketplaceModal = ({ }; export default ClosersMarketplaceModal; - diff --git a/src/components/common/TechStackSection.tsx b/src/components/common/TechStackSection.tsx index 119439f1..287d4549 100644 --- a/src/components/common/TechStackSection.tsx +++ b/src/components/common/TechStackSection.tsx @@ -149,7 +149,7 @@ export const TechStackSection = ({ > <div className="rounded-xl bg-background-dark/30 p-4 shadow-lg backdrop-blur-md sm:p-8"> <div - className={`grid ${getGridColumns(stack.libraries.length)} gap-4 justify-items-center`} + className={`grid ${getGridColumns(stack.libraries.length)} justify-items-center gap-4`} > {stack.libraries.map((lib) => ( <TooltipProvider key={lib.name}> diff --git a/src/components/deal_scale/talkingCards/session/AudioManager.tsx b/src/components/deal_scale/talkingCards/session/AudioManager.tsx index 896fa663..d5c756c1 100644 --- a/src/components/deal_scale/talkingCards/session/AudioManager.tsx +++ b/src/components/deal_scale/talkingCards/session/AudioManager.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import type { LineStatus } from "@/types/transcript"; +import { useEffect, useRef, useState } from "react"; interface AudioManagerProps { callStatus: LineStatus; diff --git a/src/components/deal_scale/talkingCards/session/CallCompleteModal.tsx b/src/components/deal_scale/talkingCards/session/CallCompleteModal.tsx index e2550cd8..dacdc416 100644 --- a/src/components/deal_scale/talkingCards/session/CallCompleteModal.tsx +++ b/src/components/deal_scale/talkingCards/session/CallCompleteModal.tsx @@ -1,7 +1,5 @@ "use client"; -import { Loader2 } from "lucide-react"; -import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -12,6 +10,8 @@ import { } from "@/components/ui/dialog"; import { useNavigationRouter } from "@/hooks/useNavigationRouter"; import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; interface CallCompleteModalProps { /** Whether the modal is open */ diff --git a/src/components/faq/index.tsx b/src/components/faq/index.tsx index d441643a..e5b6424f 100644 --- a/src/components/faq/index.tsx +++ b/src/components/faq/index.tsx @@ -104,7 +104,7 @@ const FAQ: React.FC<FAQProps> = ({ title, subtitle, faqItems }) => { <Button variant="outline" onClick={() => setShowAll(true)} - className="group flex items-center gap-2 border-primary/40 bg-background/80 hover:bg-primary/10 hover:border-primary/60" + className="group flex items-center gap-2 border-primary/40 bg-background/80 hover:border-primary/60 hover:bg-primary/10" > Load More FAQs <ChevronDown className="h-4 w-4 transition-transform group-hover:translate-y-0.5" /> diff --git a/src/components/home/BlogPreview.tsx b/src/components/home/BlogPreview.tsx index f52a40f6..50dd7dca 100644 --- a/src/components/home/BlogPreview.tsx +++ b/src/components/home/BlogPreview.tsx @@ -10,11 +10,11 @@ import { CarouselNext, CarouselPrevious, } from "@/components/ui/carousel"; +import { AI_OUTREACH_STUDIO_ANCHOR } from "@/data/home/aiOutreachStudio"; import { useIsMobile } from "@/hooks/use-mobile"; import { useHasMounted } from "@/hooks/useHasMounted"; import { cn } from "@/lib/utils"; import type { BeehiivPost } from "@/types/behiiv"; -import { AI_OUTREACH_STUDIO_ANCHOR } from "@/data/home/aiOutreachStudio"; import { motion } from "framer-motion"; import { ArrowLeft, ArrowRight, Youtube } from "lucide-react"; import Link from "next/link"; @@ -136,7 +136,7 @@ export function BlogPreview({ onClick={(e) => { e.preventDefault(); console.log("[View Shorts] Button clicked"); - + // Dispatch custom event to trigger video mode window.dispatchEvent( new CustomEvent("play-youtube-shorts", { @@ -144,39 +144,54 @@ export function BlogPreview({ }), ); console.log("[View Shorts] Event dispatched"); - + // Smooth scroll to AI Outreach Demo section, centering the demo area setTimeout(() => { - const element = document.getElementById(AI_OUTREACH_STUDIO_ANCHOR); - console.log("[View Shorts] Looking for element:", AI_OUTREACH_STUDIO_ANCHOR, element); - + const element = document.getElementById( + AI_OUTREACH_STUDIO_ANCHOR, + ); + console.log( + "[View Shorts] Looking for element:", + AI_OUTREACH_STUDIO_ANCHOR, + element, + ); + if (element) { // Find the actual demo/phone container within the section - const demoContainer = element.querySelector('[class*="PhoneShell"], [class*="Iphone"], iframe'); + const demoContainer = element.querySelector( + '[class*="PhoneShell"], [class*="Iphone"], iframe', + ); const targetElement = demoContainer || element; - + // Calculate offset to center the demo area const headerHeight = 100; const viewportHeight = window.innerHeight; const elementRect = targetElement.getBoundingClientRect(); const elementTop = elementRect.top + window.pageYOffset; - + // Center the element in viewport - const offsetPosition = elementTop - (viewportHeight / 2) + (elementRect.height / 2) - headerHeight; - + const offsetPosition = + elementTop - + viewportHeight / 2 + + elementRect.height / 2 - + headerHeight; + console.log("[View Shorts] Scrolling to:", { elementTop, viewportHeight, offsetPosition, elementHeight: elementRect.height, }); - + window.scrollTo({ top: Math.max(0, offsetPosition), behavior: "smooth", }); } else { - console.error("[View Shorts] Element not found:", AI_OUTREACH_STUDIO_ANCHOR); + console.error( + "[View Shorts] Element not found:", + AI_OUTREACH_STUDIO_ANCHOR, + ); } }, 200); }} @@ -191,7 +206,10 @@ export function BlogPreview({ variant="default" className="w-fit bg-gradient-to-r from-primary to-focus text-white shadow-lg hover:opacity-90 hover:shadow-xl" > - <Link href="/blogs" className="flex items-center justify-center"> + <Link + href="/blogs" + className="flex items-center justify-center" + > View All Articles <ArrowRight className="ml-2 h-4 w-4" /> </Link> diff --git a/src/components/home/CallDemoShowcase.tsx b/src/components/home/CallDemoShowcase.tsx index ff8fa113..082766c0 100644 --- a/src/components/home/CallDemoShowcase.tsx +++ b/src/components/home/CallDemoShowcase.tsx @@ -151,7 +151,7 @@ const PhoneShell = ({ }) => ( <div className={cn( - "relative w-full max-w-[22rem] rounded-[3.25rem] p-3 backdrop-blur-md shadow-[0_35px_80px_rgba(15,23,42,0.55)] ring-1 sm:max-w-[24rem] md:max-w-[26rem]", + "relative w-full max-w-[22rem] rounded-[3.25rem] p-3 shadow-[0_35px_80px_rgba(15,23,42,0.55)] ring-1 backdrop-blur-md sm:max-w-[24rem] md:max-w-[26rem]", "bg-slate-800/60 ring-slate-700/50", "dark:bg-slate-900/70 dark:ring-white/12", className, @@ -217,10 +217,12 @@ const CallHandoffCard = ({ className="size-full object-cover" /> </div> - <h3 className="font-semibold text-lg text-slate-900 dark:text-white">Jordan, DealScale AI Rep</h3> + <h3 className="font-semibold text-lg text-slate-900 dark:text-white"> + Jordan, DealScale AI Rep + </h3> <p className="text-slate-600 text-sm dark:text-slate-300"> - Lead is ready to confirm. Est. commission: $15,000. Accept and we'll sync the handoff to - your CRM. + Lead is ready to confirm. Est. commission: $15,000. Accept and + we'll sync the handoff to your CRM. </p> </div> </div> @@ -244,27 +246,27 @@ const CallHandoffCard = ({ </div> </div> <div className="relative flex w-full flex-shrink-0 flex-col gap-3"> - <button - type="button" - onClick={onAccept} - className="inline-flex w-full items-center justify-center rounded-full bg-emerald-500 px-5 py-2.5 font-semibold text-sm text-white shadow-emerald-500/40 shadow-lg transition hover:bg-emerald-400 dark:bg-emerald-500 dark:hover:bg-emerald-400" - > - Accept Appointment - </button> - <button - type="button" - onClick={onQueue} - className="inline-flex w-full items-center justify-center rounded-full border border-slate-300/30 bg-slate-50/90 px-5 py-2.5 font-medium text-sm text-slate-900 transition hover:bg-slate-100/90 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10" - > - Send to Follow-up Queue - </button> - <button - type="button" - onClick={onCancel} - className="inline-flex w-full items-center justify-center rounded-full border border-slate-300/20 bg-transparent px-5 py-2.5 font-medium text-sm text-slate-700 transition hover:bg-slate-100/50 dark:border-white/10 dark:text-white dark:hover:bg-white/10" - > - Cancel - </button> + <button + type="button" + onClick={onAccept} + className="inline-flex w-full items-center justify-center rounded-full bg-emerald-500 px-5 py-2.5 font-semibold text-sm text-white shadow-emerald-500/40 shadow-lg transition hover:bg-emerald-400 dark:bg-emerald-500 dark:hover:bg-emerald-400" + > + Accept Appointment + </button> + <button + type="button" + onClick={onQueue} + className="inline-flex w-full items-center justify-center rounded-full border border-slate-300/30 bg-slate-50/90 px-5 py-2.5 font-medium text-slate-900 text-sm transition hover:bg-slate-100/90 dark:border-white/20 dark:bg-white/5 dark:text-white dark:hover:bg-white/10" + > + Send to Follow-up Queue + </button> + <button + type="button" + onClick={onCancel} + className="inline-flex w-full items-center justify-center rounded-full border border-slate-300/20 bg-transparent px-5 py-2.5 font-medium text-slate-700 text-sm transition hover:bg-slate-100/50 dark:border-white/10 dark:text-white dark:hover:bg-white/10" + > + Cancel + </button> </div> </div> ); diff --git a/src/components/home/ProcessingStatusList.tsx b/src/components/home/ProcessingStatusList.tsx index 9208a181..cb750c1b 100644 --- a/src/components/home/ProcessingStatusList.tsx +++ b/src/components/home/ProcessingStatusList.tsx @@ -1,8 +1,15 @@ "use client"; -import { motion, AnimatePresence } from "framer-motion"; -import { CheckCircle2, Loader2, Phone, MessageSquare, Database, Zap } from "lucide-react"; import { cn } from "@/lib/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { + CheckCircle2, + Database, + Loader2, + MessageSquare, + Phone, + Zap, +} from "lucide-react"; interface ProcessingStep { id: string; @@ -35,12 +42,7 @@ export function ProcessingStatusList({ className, }: ProcessingStatusListProps) { return ( - <div - className={cn( - "w-full space-y-2", - className, - )} - > + <div className={cn("w-full space-y-2", className)}> <div className="space-y-2"> <AnimatePresence> {steps.map((step, index) => ( @@ -53,10 +55,11 @@ export function ProcessingStatusList({ className={cn( "flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors", step.status === "processing" && - "bg-sky-500/10 border border-sky-500/30", + "border border-sky-500/30 bg-sky-500/10", step.status === "completed" && - "bg-green-500/10 border border-green-500/30", - step.status === "pending" && "bg-white/5 border border-white/10", + "border border-green-500/30 bg-green-500/10", + step.status === "pending" && + "border border-white/10 bg-white/5", )} > <div @@ -80,7 +83,7 @@ export function ProcessingStatusList({ {step.icon} <span className={cn( - "text-sm font-medium transition-colors", + "font-medium text-sm transition-colors", step.status === "processing" && "text-sky-300", step.status === "completed" && "text-green-300", step.status === "pending" && "text-white/60", @@ -97,4 +100,3 @@ export function ProcessingStatusList({ </div> ); } - diff --git a/src/components/home/ReactivateCampaignBadges.tsx b/src/components/home/ReactivateCampaignBadges.tsx index c1fa3ebf..ae64b1a8 100644 --- a/src/components/home/ReactivateCampaignBadges.tsx +++ b/src/components/home/ReactivateCampaignBadges.tsx @@ -1,8 +1,8 @@ "use client"; -import { motion, type Variants } from "framer-motion"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import { type Variants, motion } from "framer-motion"; export interface BadgeMetrics { dollarAmount: number; diff --git a/src/components/home/ReactivateCampaignInput.tsx b/src/components/home/ReactivateCampaignInput.tsx index e254f0db..2dca6a35 100644 --- a/src/components/home/ReactivateCampaignInput.tsx +++ b/src/components/home/ReactivateCampaignInput.tsx @@ -1,27 +1,8 @@ "use client"; -import { useState, useRef, useCallback, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { - Search, - Upload, - FileIcon, - X, - Loader2, - Play, - Info, - Download, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; +import { useHeroTrialCheckout } from "@/components/home/heros/useHeroTrialCheckout"; import { Badge } from "@/components/ui/badge"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -29,26 +10,45 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import dynamic from "next/dynamic"; -import { useHeroTrialCheckout } from "@/components/home/heros/useHeroTrialCheckout"; -import { toast } from "sonner"; +import { TypingAnimation } from "@/components/ui/typing-animation"; import { cn } from "@/lib/utils"; -import { useSession } from "next-auth/react"; -import { parseContactFile, type ContactData } from "@/utils/csvParser"; +import { type ContactData, parseContactFile } from "@/utils/csvParser"; +import { AnimatePresence, motion } from "framer-motion"; import { - ReactivateCampaignBadges, - type BadgeMetrics, -} from "./ReactivateCampaignBadges"; -import { TypingAnimation } from "@/components/ui/typing-animation"; -import { ProcessingStatusList } from "./ProcessingStatusList"; + Download, + FileIcon, + Info, + Loader2, + Play, + Search, + Upload, + X, +} from "lucide-react"; import { - Phone, - MessageSquare, - Database, CheckCircle2, + Database, + MessageSquare, + Phone, Zap, } from "lucide-react"; +import { useSession } from "next-auth/react"; +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { ProcessingStatusList } from "./ProcessingStatusList"; +import { + type BadgeMetrics, + ReactivateCampaignBadges, +} from "./ReactivateCampaignBadges"; interface ReactivateCampaignInputProps { onActivationComplete?: (metrics: BadgeMetrics) => void; @@ -397,35 +397,45 @@ export function ReactivateCampaignInput({ // Build redirect URL to app.dealscale.io with tracking parameters const redirectUrl = new URL("https://app.dealscale.io"); - + // Add user ID if available if (session?.user?.id) { redirectUrl.searchParams.append("userId", session.user.id); } - + // Add contacts count - redirectUrl.searchParams.append("contactsCount", String(contacts.length)); - + redirectUrl.searchParams.append( + "contactsCount", + String(contacts.length), + ); + // Add payment intent ID if available from checkout state if (checkoutState?.clientSecret) { // Extract payment intent ID from client secret (format: "pi_xxx_secret_yyy") - const paymentIntentId = checkoutState.clientSecret.split("_secret")[0]; + const paymentIntentId = + checkoutState.clientSecret.split("_secret")[0]; if (paymentIntentId) { redirectUrl.searchParams.append("paymentIntentId", paymentIntentId); } } - + // Add source identifier redirectUrl.searchParams.append("source", "homepage_hero"); - + // Add activated contacts count from metrics if (calculatedMetrics.contactsActivated > 0) { - redirectUrl.searchParams.append("activatedCount", String(calculatedMetrics.contactsActivated)); + redirectUrl.searchParams.append( + "activatedCount", + String(calculatedMetrics.contactsActivated), + ); } // Redirect to app.dealscale.io after a short delay setTimeout(() => { - console.log("[ReactivateCampaign] Redirecting to app.dealscale.io with tracking:", redirectUrl.toString()); + console.log( + "[ReactivateCampaign] Redirecting to app.dealscale.io with tracking:", + redirectUrl.toString(), + ); window.location.href = redirectUrl.toString(); }, 2000); }, 1600); @@ -465,7 +475,7 @@ export function ReactivateCampaignInput({ className="w-full" > {/* Search/Upload Bar Container */} - <div className="relative flex w-full flex-col gap-3 rounded-2xl border border-sky-500/30 bg-white/10 px-4 py-4 shadow-[0_8px_30px_rgba(59,130,246,0.2)] backdrop-blur-xl transition-all duration-300 focus-within:border-sky-400 focus-within:shadow-[0_8px_30px_rgba(59,130,246,0.4)] dark:border-sky-500/40 dark:bg-slate-800/30 dark:focus-within:border-sky-500 dark:focus-within:shadow-[0_8px_30px_rgba(59,130,246,0.3)] bg-white/80 dark:bg-white/10 border-sky-400/50 dark:border-sky-500/40"> + <div className="relative flex w-full flex-col gap-3 rounded-2xl border border-sky-400/50 border-sky-500/30 bg-white/10 bg-white/80 px-4 py-4 shadow-[0_8px_30px_rgba(59,130,246,0.2)] backdrop-blur-xl transition-all duration-300 focus-within:border-sky-400 focus-within:shadow-[0_8px_30px_rgba(59,130,246,0.4)] dark:border-sky-500/40 dark:border-sky-500/40 dark:bg-slate-800/30 dark:bg-white/10 dark:focus-within:border-sky-500 dark:focus-within:shadow-[0_8px_30px_rgba(59,130,246,0.3)]"> {/* Search Input */} <div className="relative flex flex-col gap-2"> <div className="relative flex items-center gap-3"> @@ -525,13 +535,13 @@ export function ReactivateCampaignInput({ </Button> </div> {/* Mobile-only hint */} - <p className="text-xs text-slate-600 dark:text-white/60 sm:hidden"> + <p className="text-slate-600 text-xs sm:hidden dark:text-white/60"> Upload your lead list </p> </div> {/* File Upload Section */} - <div className="flex flex-col gap-3 border-slate-200/50 dark:border-white/10 border-t pt-3"> + <div className="flex flex-col gap-3 border-slate-200/50 border-t pt-3 dark:border-white/10"> <input ref={fileInputRef} type="file" @@ -541,7 +551,9 @@ export function ReactivateCampaignInput({ disabled={isProcessing} /> {/* File Upload Buttons Row with Enrich Toggle on same line when no file */} - <div className={`flex flex-wrap items-center gap-2 sm:gap-3 ${uploadedFile ? "flex-col sm:flex-row" : ""}`}> + <div + className={`flex flex-wrap items-center gap-2 sm:gap-3 ${uploadedFile ? "flex-col sm:flex-row" : ""}`} + > <div className="flex flex-wrap items-center gap-2 sm:gap-3"> <Button type="button" @@ -572,13 +584,16 @@ export function ReactivateCampaignInput({ </div> {uploadedFile && ( - <div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg bg-slate-50/80 dark:bg-white/5 px-2 py-1.5 sm:px-3 sm:py-2"> + <div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg bg-slate-50/80 px-2 py-1.5 sm:px-3 sm:py-2 dark:bg-white/5"> <FileIcon className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-400" /> - <span className="min-w-0 truncate text-xs text-slate-700 dark:text-white sm:text-sm"> + <span className="min-w-0 truncate text-slate-700 text-xs sm:text-sm dark:text-white"> {uploadedFile.name} </span> {contacts.length > 0 && ( - <Badge variant="secondary" className="ml-auto shrink-0 sm:ml-2"> + <Badge + variant="secondary" + className="ml-auto shrink-0 sm:ml-2" + > {contacts.length} contacts </Badge> )} @@ -586,7 +601,7 @@ export function ReactivateCampaignInput({ type="button" onClick={handleRemoveFile} disabled={isProcessing} - className="ml-1 shrink-0 rounded-full p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white sm:ml-2" + className="ml-1 shrink-0 rounded-full p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 sm:ml-2 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" aria-label="Remove file" > <X className="h-3 w-3" /> @@ -595,91 +610,98 @@ export function ReactivateCampaignInput({ )} {/* Enrich Toggle with Info - on same line when no file, new line when file uploaded */} - <div className={`flex items-center gap-1.5 sm:gap-2 ${uploadedFile ? "w-full sm:w-auto sm:ml-auto" : "ml-auto"}`}> - <Label - htmlFor="enrich" - className="flex cursor-pointer items-center gap-1 text-sm text-slate-700 dark:text-white/90 sm:gap-1.5" + <div + className={`flex items-center gap-1.5 sm:gap-2 ${uploadedFile ? "w-full sm:ml-auto sm:w-auto" : "ml-auto"}`} > - <span className="hidden sm:inline">Enrich</span> - {/* Mobile: Use Dialog, Desktop: Use Popover */} - <> - <button - type="button" - className="focus:outline-none sm:hidden" - aria-label="Learn more about enrichment" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setShowEnrichInfo(true); - }} - > - <Info className="h-4 w-4 text-sky-500 opacity-90 transition-opacity active:opacity-100 dark:text-sky-400" /> - </button> - <Popover> - <PopoverTrigger asChild> - <button - type="button" - className="hidden focus:outline-none sm:block" - aria-label="Learn more about enrichment" - > - <Info className="h-3.5 w-3.5 text-sky-500 opacity-80 transition-opacity hover:opacity-100 dark:text-sky-400" /> - </button> - </PopoverTrigger> - <PopoverContent - className="z-50 w-80 border-sky-500/30 bg-background-dark text-white" - side="top" - align="end" + <Label + htmlFor="enrich" + className="flex cursor-pointer items-center gap-1 text-slate-700 text-sm sm:gap-1.5 dark:text-white/90" + > + <span className="hidden sm:inline">Enrich</span> + {/* Mobile: Use Dialog, Desktop: Use Popover */} + <> + <button + type="button" + className="focus:outline-none sm:hidden" + aria-label="Learn more about enrichment" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setShowEnrichInfo(true); + }} > - <div className="space-y-3"> - <div> - <h4 className="mb-2 font-semibold text-base text-white"> - What is Enrichment? - </h4> - <p className="text-sm text-white/80 leading-relaxed"> - Automatically enhance your contact data with verified - phone numbers, email addresses, and additional - information to improve your outreach success rate. - </p> - </div> - <div> - <h5 className="mb-2 font-semibold text-sky-400 text-sm"> - What you get: - </h5> - <ul className="space-y-1.5 text-sm text-white/80"> - <li className="flex items-start gap-2"> - <span className="mt-0.5 text-sky-400">✓</span> - <span> - Verified contact information (phone, email) - </span> - </li> - <li className="flex items-start gap-2"> - <span className="mt-0.5 text-sky-400">✓</span> - <span>Enhanced lead data for better targeting</span> - </li> - <li className="flex items-start gap-2"> - <span className="mt-0.5 text-sky-400">✓</span> - <span> - Higher conversion rates with accurate contacts - </span> - </li> - <li className="flex items-start gap-2"> - <span className="mt-0.5 text-sky-400">✓</span> - <span>Time saved on manual data verification</span> - </li> - </ul> + <Info className="h-4 w-4 text-sky-500 opacity-90 transition-opacity active:opacity-100 dark:text-sky-400" /> + </button> + <Popover> + <PopoverTrigger asChild> + <button + type="button" + className="hidden focus:outline-none sm:block" + aria-label="Learn more about enrichment" + > + <Info className="h-3.5 w-3.5 text-sky-500 opacity-80 transition-opacity hover:opacity-100 dark:text-sky-400" /> + </button> + </PopoverTrigger> + <PopoverContent + className="z-50 w-80 border-sky-500/30 bg-background-dark text-white" + side="top" + align="end" + > + <div className="space-y-3"> + <div> + <h4 className="mb-2 font-semibold text-base text-white"> + What is Enrichment? + </h4> + <p className="text-sm text-white/80 leading-relaxed"> + Automatically enhance your contact data with + verified phone numbers, email addresses, and + additional information to improve your outreach + success rate. + </p> + </div> + <div> + <h5 className="mb-2 font-semibold text-sky-400 text-sm"> + What you get: + </h5> + <ul className="space-y-1.5 text-sm text-white/80"> + <li className="flex items-start gap-2"> + <span className="mt-0.5 text-sky-400">✓</span> + <span> + Verified contact information (phone, email) + </span> + </li> + <li className="flex items-start gap-2"> + <span className="mt-0.5 text-sky-400">✓</span> + <span> + Enhanced lead data for better targeting + </span> + </li> + <li className="flex items-start gap-2"> + <span className="mt-0.5 text-sky-400">✓</span> + <span> + Higher conversion rates with accurate contacts + </span> + </li> + <li className="flex items-start gap-2"> + <span className="mt-0.5 text-sky-400">✓</span> + <span> + Time saved on manual data verification + </span> + </li> + </ul> + </div> </div> - </div> - </PopoverContent> - </Popover> - </> - </Label> - <Switch - id="enrich" - checked={skipTrace} - onCheckedChange={setSkipTrace} - disabled={isProcessing} - className="shrink-0 h-7 w-12 border-2 border-slate-300/50 data-[state=checked]:bg-sky-500 data-[state=checked]:border-sky-500 data-[state=unchecked]:bg-slate-200/80 dark:border-slate-600/50 dark:data-[state=checked]:bg-sky-500 dark:data-[state=checked]:border-sky-500 dark:data-[state=unchecked]:bg-slate-700/80 [&>span]:h-6 [&>span]:w-6 [&>span]:shadow-md" - /> + </PopoverContent> + </Popover> + </> + </Label> + <Switch + id="enrich" + checked={skipTrace} + onCheckedChange={setSkipTrace} + disabled={isProcessing} + className="h-7 w-12 shrink-0 border-2 border-slate-300/50 data-[state=checked]:border-sky-500 data-[state=checked]:bg-sky-500 data-[state=unchecked]:bg-slate-200/80 dark:border-slate-600/50 dark:data-[state=checked]:border-sky-500 dark:data-[state=checked]:bg-sky-500 dark:data-[state=unchecked]:bg-slate-700/80 [&>span]:h-6 [&>span]:w-6 [&>span]:shadow-md" + /> </div> </div> </div> @@ -706,7 +728,7 @@ export function ReactivateCampaignInput({ {/* Enrich Info Dialog for Mobile */} <Dialog open={showEnrichInfo} onOpenChange={setShowEnrichInfo}> - <DialogContent className="border-sky-500/30 bg-slate-900/95 backdrop-blur-xl sm:max-w-md [&>button]:text-white [&>button]:hover:text-white/80 [&>button]:hover:bg-white/10"> + <DialogContent className="border-sky-500/30 bg-slate-900/95 backdrop-blur-xl sm:max-w-md [&>button]:text-white [&>button]:hover:bg-white/10 [&>button]:hover:text-white/80"> <DialogHeader> <DialogTitle className="text-white"> Enrichment & AI Outreach @@ -717,10 +739,16 @@ export function ReactivateCampaignInput({ </DialogHeader> <Tabs defaultValue="enrichment" className="w-full"> <TabsList className="grid w-full grid-cols-2 bg-slate-800/50"> - <TabsTrigger value="enrichment" className="text-white data-[state=active]:bg-sky-500 data-[state=active]:text-white"> + <TabsTrigger + value="enrichment" + className="text-white data-[state=active]:bg-sky-500 data-[state=active]:text-white" + > Enrichment </TabsTrigger> - <TabsTrigger value="ai-outreach" className="text-white data-[state=active]:bg-sky-500 data-[state=active]:text-white"> + <TabsTrigger + value="ai-outreach" + className="text-white data-[state=active]:bg-sky-500 data-[state=active]:text-white" + > AI Outreach </TabsTrigger> </TabsList> @@ -730,9 +758,9 @@ export function ReactivateCampaignInput({ What is Enrichment? </h4> <p className="text-sm text-white/80 leading-relaxed"> - Automatically enhance your contact data with verified - phone numbers, email addresses, and additional - information to improve your outreach success rate. + Automatically enhance your contact data with verified phone + numbers, email addresses, and additional information to + improve your outreach success rate. </p> </div> <div> @@ -742,9 +770,7 @@ export function ReactivateCampaignInput({ <ul className="space-y-1.5 text-sm text-white/80"> <li className="flex items-start gap-2"> <span className="mt-0.5 text-sky-400">✓</span> - <span> - Verified contact information (phone, email) - </span> + <span>Verified contact information (phone, email)</span> </li> <li className="flex items-start gap-2"> <span className="mt-0.5 text-sky-400">✓</span> @@ -769,7 +795,11 @@ export function ReactivateCampaignInput({ What is AI Outreach? </h4> <p className="text-sm text-white/80 leading-relaxed"> - AI Outreach is DealScale's intelligent automation system that handles your entire lead engagement workflow. It uses advanced AI to make calls, send personalized messages, and nurture leads automatically, so you can focus on closing deals instead of chasing contacts. + AI Outreach is DealScale's intelligent automation system + that handles your entire lead engagement workflow. It uses + advanced AI to make calls, send personalized messages, and + nurture leads automatically, so you can focus on closing + deals instead of chasing contacts. </p> </div> <div> @@ -790,7 +820,8 @@ export function ReactivateCampaignInput({ <li className="flex items-start gap-2"> <span className="mt-0.5 text-sky-400">✓</span> <span> - CRM integration that syncs every interaction automatically + CRM integration that syncs every interaction + automatically </span> </li> <li className="flex items-start gap-2"> @@ -799,7 +830,9 @@ export function ReactivateCampaignInput({ </li> <li className="flex items-start gap-2"> <span className="mt-0.5 text-sky-400">✓</span> - <span>Personalized messaging based on lead behavior and data</span> + <span> + Personalized messaging based on lead behavior and data + </span> </li> </ul> </div> diff --git a/src/components/home/heros/live-dynamic-hero-demo/HeroSideBySide.tsx b/src/components/home/heros/live-dynamic-hero-demo/HeroSideBySide.tsx index 51362203..c667d9c1 100644 --- a/src/components/home/heros/live-dynamic-hero-demo/HeroSideBySide.tsx +++ b/src/components/home/heros/live-dynamic-hero-demo/HeroSideBySide.tsx @@ -5,20 +5,20 @@ import { resolveHeroThumbnailSrc, useHeroVideoConfig, } from "@external/dynamic-hero"; +import { motion } from "framer-motion"; import dynamic from "next/dynamic"; import Image from "next/image"; -import { useCallback, useRef, useState, useEffect } from "react"; -import { motion } from "framer-motion"; +import { useCallback, useEffect, useRef, useState } from "react"; import PersonaCTA from "@/components/cta/PersonaCTA"; +import type { BadgeMetrics } from "@/components/home/ReactivateCampaignBadges"; +import { ReactivateCampaignInput } from "@/components/home/ReactivateCampaignInput"; import { useHeroTrialCheckout } from "@/components/home/heros/useHeroTrialCheckout"; import { AvatarCircles } from "@/components/ui/avatar-circles"; import { InteractiveGridPattern } from "@/components/ui/interactive-grid-pattern"; -import { ReactivateCampaignInput } from "@/components/home/ReactivateCampaignInput"; -import type { BadgeMetrics } from "@/components/home/ReactivateCampaignBadges"; import { Particles } from "@/components/ui/particles"; -import { useTheme } from "next-themes"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useTheme } from "next-themes"; import { LIVE_COPY, @@ -152,7 +152,7 @@ export default function HeroSideBySide(): JSX.Element { <InteractiveGridPattern width={72} height={72} - className="z-[1] opacity-20 dark:opacity-40 md:opacity-25 md:dark:opacity-50" + className="z-[1] opacity-20 md:opacity-25 dark:opacity-40 md:dark:opacity-50" squares={[20, 20]} squaresClassName="stroke-border/25 dark:stroke-border/50" /> @@ -184,7 +184,7 @@ export default function HeroSideBySide(): JSX.Element { {/* Centered text content */} <div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-6 overflow-x-visible text-center md:gap-8"> {/* Persona Badge */} - <span className="inline-flex max-w-[calc(100vw-2rem)] shrink-0 items-center justify-center rounded-full border border-border/40 bg-background/70 px-2 py-1.5 font-semibold text-foreground/80 text-[8px] uppercase leading-tight tracking-[0.05em] sm:max-w-fit sm:px-3 sm:text-[10px] sm:tracking-[0.15em] md:px-4 md:text-xs md:tracking-[0.25em]"> + <span className="inline-flex max-w-[calc(100vw-2rem)] shrink-0 items-center justify-center rounded-full border border-border/40 bg-background/70 px-2 py-1.5 font-semibold text-[8px] text-foreground/80 uppercase leading-tight tracking-[0.05em] sm:max-w-fit sm:px-3 sm:text-[10px] sm:tracking-[0.15em] md:px-4 md:text-xs md:tracking-[0.25em]"> <span className="whitespace-nowrap">{PERSONA_LABEL}</span> </span> diff --git a/src/components/home/pricing/PricingCheckoutDialog.tsx b/src/components/home/pricing/PricingCheckoutDialog.tsx index 7aeb144f..26f3537f 100644 --- a/src/components/home/pricing/PricingCheckoutDialog.tsx +++ b/src/components/home/pricing/PricingCheckoutDialog.tsx @@ -76,7 +76,7 @@ export default function PricingCheckoutDialog({ <p className="mb-4 font-semibold text-lg"> We couldn't initialize the checkout experience. </p> - <p className="mb-6 text-sm text-muted-foreground"> + <p className="mb-6 text-muted-foreground text-sm"> Please refresh the page or contact support for assistance. </p> <Button onClick={onClose} variant="secondary"> diff --git a/src/components/layout/FooterBetaCta.tsx b/src/components/layout/FooterBetaCta.tsx index e36f2f2b..0709823f 100644 --- a/src/components/layout/FooterBetaCta.tsx +++ b/src/components/layout/FooterBetaCta.tsx @@ -1,7 +1,5 @@ "use client"; -import { ArrowRight } from "lucide-react"; -import Link from "next/link"; import { FOUNDERS_CIRCLE_DEADLINE, FOUNDERS_CIRCLE_DEADLINE_ISO, @@ -9,6 +7,8 @@ import { import { useCountdown } from "@/hooks/useCountdown"; import { useHasMounted } from "@/hooks/useHasMounted"; import { cn } from "@/lib/utils"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; import { Button } from "../ui/button"; /** diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index d4ffcf1c..24735ab9 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -16,8 +16,8 @@ import { PixelatedCanvas } from "@/components/ui/pixelated-canvas"; import { navItems } from "@/data/layout/nav"; import { useHasMounted } from "@/hooks/useHasMounted"; import { Z_INDEX } from "@/lib/constants/z-index"; -import { isBannerClosed, setBannerClosed } from "@/lib/utils/cookies"; import { cn } from "@/lib/utils"; +import { isBannerClosed, setBannerClosed } from "@/lib/utils/cookies"; import { ChevronDown, Menu, X } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; @@ -65,7 +65,13 @@ const NavLink = ({ item, onClick, className = "" }) => { ); }; -const MegaMenuLink = ({ href, title, icon, className, onClick = undefined }) => { +const MegaMenuLink = ({ + href, + title, + icon, + className, + onClick = undefined, +}) => { const isHighlighted = title.toLowerCase().includes("contact") || title.toLowerCase().includes("schedule"); @@ -556,7 +562,7 @@ export default function Navbar() { // 1. Not on excluded pages (contact, contact-pilot) // 2. Either on affiliate page OR not closed if (!isExcludedPage && (isAffiliatePage || !bannerClosed)) { - setShowBanner(true); + setShowBanner(true); } else { setShowBanner(false); } diff --git a/src/components/products/ProductGrid.tsx b/src/components/products/ProductGrid.tsx index 14672fed..2d2fc5b2 100644 --- a/src/components/products/ProductGrid.tsx +++ b/src/components/products/ProductGrid.tsx @@ -173,27 +173,44 @@ const ProductGrid: React.FC<ProductGridProps> = ({ products, callbackUrl }) => { .includes(activeCategory), ); } - + // For Monetize category: Only show marketplace entry points where users can EARN income if (activeCategory === ProductCategory.Monetize) { filtered = filtered.filter((product) => { // Exclude products where users BUY things (they don't earn from these): // 1. Individual closers (users hire them, closers earn but buyers don't) - const isIndividualCloser = product.id?.startsWith("closer-") || product.sku?.startsWith("DS-CLOSER-"); + const isIndividualCloser = + product.id?.startsWith("closer-") || + product.sku?.startsWith("DS-CLOSER-"); // 2. Individual workflows (users BUY these, they don't earn from them) - const isIndividualWorkflow = product.id?.includes("-workflow") || product.sku?.startsWith("WF-"); + const isIndividualWorkflow = + product.id?.includes("-workflow") || product.sku?.startsWith("WF-"); // 3. Individual agents (users BUY these, they don't earn from them) - const isIndividualAgent = product.id?.includes("-agent") || product.id?.includes("-concierge") || product.sku?.startsWith("AG-"); + const isIndividualAgent = + product.id?.includes("-agent") || + product.id?.includes("-concierge") || + product.sku?.startsWith("AG-"); // 4. Free resources (users download these for free, they don't earn from them) - const isFreeResource = product.categories?.includes(ProductCategory.FreeResources); + const isFreeResource = product.categories?.includes( + ProductCategory.FreeResources, + ); // 5. Products with price > 0 where users pay (not earn) - const isBuyableProduct = product.price > 0 && !product.id?.includes("marketplace") && !product.sku?.includes("MARKETPLACE"); - + const isBuyableProduct = + product.price > 0 && + !product.id?.includes("marketplace") && + !product.sku?.includes("MARKETPLACE"); + // Exclude these - only show marketplace entry points where users can APPLY/SELL to earn - if (isIndividualCloser || isIndividualWorkflow || isIndividualAgent || isFreeResource || isBuyableProduct) { + if ( + isIndividualCloser || + isIndividualWorkflow || + isIndividualAgent || + isFreeResource || + isBuyableProduct + ) { return false; } - + // Include only marketplace entry points where users can EARN: // - Remote Closers marketplace (users can APPLY to become closers and earn) // - Sales Scripts marketplace (users can SELL scripts and earn) @@ -201,7 +218,7 @@ const ProductGrid: React.FC<ProductGridProps> = ({ products, callbackUrl }) => { return true; }); } - + if (searchTerm.trim()) { const term = searchTerm.trim().toLowerCase(); filtered = filtered.filter( diff --git a/src/components/products/product/card/ProductHeader.tsx b/src/components/products/product/card/ProductHeader.tsx index a41661c9..95a3988f 100644 --- a/src/components/products/product/card/ProductHeader.tsx +++ b/src/components/products/product/card/ProductHeader.tsx @@ -1,5 +1,5 @@ -import Link from "next/link"; import { ProductCategory } from "@/types/products"; +import Link from "next/link"; import type { ProductHeaderProps } from "./types"; const ProductHeader = ({ diff --git a/src/components/products/product/card/ProductImage.tsx b/src/components/products/product/card/ProductImage.tsx index 6507aab9..4126e851 100644 --- a/src/components/products/product/card/ProductImage.tsx +++ b/src/components/products/product/card/ProductImage.tsx @@ -1,5 +1,5 @@ -import Link from "next/link"; import { ProductCategory } from "@/types/products"; +import Link from "next/link"; import type { ProductImageProps } from "./types"; const ProductImage = ({ @@ -11,7 +11,11 @@ const ProductImage = ({ // Redirect Remote Closers products to application page const isRemoteCloser = categories?.includes(ProductCategory.RemoteClosers) ?? false; - const href = isRemoteCloser ? "/closers/apply" : slug ? `/products/${slug}` : "#"; + const href = isRemoteCloser + ? "/closers/apply" + : slug + ? `/products/${slug}` + : "#"; return ( <div className="w-full pt-2"> diff --git a/src/components/ui/StatusPage.tsx b/src/components/ui/StatusPage.tsx index 8d44519d..78f63e7c 100644 --- a/src/components/ui/StatusPage.tsx +++ b/src/components/ui/StatusPage.tsx @@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button"; import { motion } from "framer-motion"; import { ArrowRight, CheckCircle, XCircle } from "lucide-react"; +import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; -import { useSession } from "next-auth/react"; import { useNavigationRouter } from "@/hooks/useNavigationRouter"; @@ -41,27 +41,30 @@ export function StatusPage({ type, className = "" }: StatusPageProps) { const source = searchParams.get("source"); if (isSuccess && source === "homepage_hero") { const redirectUrl = new URL("https://app.dealscale.io"); - + // Add user ID if available if (session?.user?.id) { redirectUrl.searchParams.append("userId", session.user.id); } - + // Add payment intent ID if available const paymentIntentId = searchParams.get("paymentIntentId"); if (paymentIntentId) { redirectUrl.searchParams.append("paymentIntentId", paymentIntentId); } - + // Add source identifier redirectUrl.searchParams.append("source", "homepage_hero"); - + // Redirect after a short delay to show success message const timer = setTimeout(() => { - console.log("[StatusPage] Redirecting to app.dealscale.io with tracking:", redirectUrl.toString()); + console.log( + "[StatusPage] Redirecting to app.dealscale.io with tracking:", + redirectUrl.toString(), + ); window.location.href = redirectUrl.toString(); }, 2000); - + return () => clearTimeout(timer); } }, [isSuccess, searchParams, session]); @@ -71,22 +74,22 @@ export function StatusPage({ type, className = "" }: StatusPageProps) { const source = searchParams.get("source"); if (source === "homepage_hero") { const redirectUrl = new URL("https://app.dealscale.io"); - + if (session?.user?.id) { redirectUrl.searchParams.append("userId", session.user.id); } - + const paymentIntentId = searchParams.get("paymentIntentId"); if (paymentIntentId) { redirectUrl.searchParams.append("paymentIntentId", paymentIntentId); } - + redirectUrl.searchParams.append("source", "homepage_hero"); - + window.location.href = redirectUrl.toString(); return; } - + if (ctaHref) { router.push(ctaHref); } diff --git a/src/components/ui/animatedList.tsx b/src/components/ui/animatedList.tsx index 9eab70c6..9a07d475 100644 --- a/src/components/ui/animatedList.tsx +++ b/src/components/ui/animatedList.tsx @@ -1,8 +1,8 @@ "use client"; +import { cn } from "@/lib/utils"; import type React from "react"; import { Children, cloneElement } from "react"; -import { cn } from "@/lib/utils"; // --- Skip Trace Example Data and Types --- @@ -48,11 +48,13 @@ export const Notification = ({ {time && ( <> <span className="mx-1">·</span> - <span className="text-gray-500 text-xs dark:text-gray-400">{time}</span> + <span className="text-gray-500 text-xs dark:text-gray-400"> + {time} + </span> </> )} </figcaption> - <p className="font-normal text-sm text-slate-600 dark:text-white/60"> + <p className="font-normal text-slate-600 text-sm dark:text-white/60"> {description} </p> </div> diff --git a/src/components/ui/particles.tsx b/src/components/ui/particles.tsx index dcb8b0df..a2a0a7e3 100644 --- a/src/components/ui/particles.tsx +++ b/src/components/ui/particles.tsx @@ -1,313 +1,316 @@ -import React, { - ComponentPropsWithoutRef, - useEffect, - useRef, - useState, -} from "react" +import type React from "react"; +import { + type ComponentPropsWithoutRef, + useEffect, + useRef, + useState, +} from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; interface MousePosition { - x: number - y: number + x: number; + y: number; } function MousePosition(): MousePosition { - const [mousePosition, setMousePosition] = useState<MousePosition>({ - x: 0, - y: 0, - }) + const [mousePosition, setMousePosition] = useState<MousePosition>({ + x: 0, + y: 0, + }); - useEffect(() => { - const handleMouseMove = (event: MouseEvent) => { - setMousePosition({ x: event.clientX, y: event.clientY }) - } + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }; - window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mousemove", handleMouseMove); - return () => { - window.removeEventListener("mousemove", handleMouseMove) - } - }, []) + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, []); - return mousePosition + return mousePosition; } interface ParticlesProps extends ComponentPropsWithoutRef<"div"> { - className?: string - quantity?: number - staticity?: number - ease?: number - size?: number - refresh?: boolean - color?: string - vx?: number - vy?: number + className?: string; + quantity?: number; + staticity?: number; + ease?: number; + size?: number; + refresh?: boolean; + color?: string; + vx?: number; + vy?: number; } function hexToRgb(hex: string): number[] { - hex = hex.replace("#", "") - - if (hex.length === 3) { - hex = hex - .split("") - .map((char) => char + char) - .join("") - } - - const hexInt = parseInt(hex, 16) - const red = (hexInt >> 16) & 255 - const green = (hexInt >> 8) & 255 - const blue = hexInt & 255 - return [red, green, blue] + hex = hex.replace("#", ""); + + if (hex.length === 3) { + hex = hex + .split("") + .map((char) => char + char) + .join(""); + } + + const hexInt = Number.parseInt(hex, 16); + const red = (hexInt >> 16) & 255; + const green = (hexInt >> 8) & 255; + const blue = hexInt & 255; + return [red, green, blue]; } type Circle = { - x: number - y: number - translateX: number - translateY: number - size: number - alpha: number - targetAlpha: number - dx: number - dy: number - magnetism: number -} + x: number; + y: number; + translateX: number; + translateY: number; + size: number; + alpha: number; + targetAlpha: number; + dx: number; + dy: number; + magnetism: number; +}; export const Particles: React.FC<ParticlesProps> = ({ - className = "", - quantity = 100, - staticity = 50, - ease = 50, - size = 0.4, - refresh = false, - color = "#ffffff", - vx = 0, - vy = 0, - ...props + className = "", + quantity = 100, + staticity = 50, + ease = 50, + size = 0.4, + refresh = false, + color = "#ffffff", + vx = 0, + vy = 0, + ...props }) => { - const canvasRef = useRef<HTMLCanvasElement>(null) - const canvasContainerRef = useRef<HTMLDivElement>(null) - const context = useRef<CanvasRenderingContext2D | null>(null) - const circles = useRef<Circle[]>([]) - const mousePosition = MousePosition() - const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }) - const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1 - const rafID = useRef<number | null>(null) - const resizeTimeout = useRef<NodeJS.Timeout | null>(null) - - useEffect(() => { - if (canvasRef.current) { - context.current = canvasRef.current.getContext("2d") - } - initCanvas() - animate() - - const handleResize = () => { - if (resizeTimeout.current) { - clearTimeout(resizeTimeout.current) - } - resizeTimeout.current = setTimeout(() => { - initCanvas() - }, 200) - } - - window.addEventListener("resize", handleResize) - - return () => { - if (rafID.current != null) { - window.cancelAnimationFrame(rafID.current) - } - if (resizeTimeout.current) { - clearTimeout(resizeTimeout.current) - } - window.removeEventListener("resize", handleResize) - } - }, [color]) - - useEffect(() => { - onMouseMove() - }, [mousePosition.x, mousePosition.y]) - - useEffect(() => { - initCanvas() - }, [refresh]) - - const initCanvas = () => { - resizeCanvas() - drawParticles() - } - - const onMouseMove = () => { - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect() - const { w, h } = canvasSize.current - const x = mousePosition.x - rect.left - w / 2 - const y = mousePosition.y - rect.top - h / 2 - const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2 - if (inside) { - mouse.current.x = x - mouse.current.y = y - } - } - } - - const resizeCanvas = () => { - if (canvasContainerRef.current && canvasRef.current && context.current) { - canvasSize.current.w = canvasContainerRef.current.offsetWidth - canvasSize.current.h = canvasContainerRef.current.offsetHeight - - canvasRef.current.width = canvasSize.current.w * dpr - canvasRef.current.height = canvasSize.current.h * dpr - canvasRef.current.style.width = `${canvasSize.current.w}px` - canvasRef.current.style.height = `${canvasSize.current.h}px` - context.current.scale(dpr, dpr) - - // Clear existing particles and create new ones with exact quantity - circles.current = [] - for (let i = 0; i < quantity; i++) { - const circle = circleParams() - drawCircle(circle) - } - } - } - - const circleParams = (): Circle => { - const x = Math.floor(Math.random() * canvasSize.current.w) - const y = Math.floor(Math.random() * canvasSize.current.h) - const translateX = 0 - const translateY = 0 - const pSize = Math.floor(Math.random() * 2) + size - const alpha = 0 - // Lower opacity for better subtlety - range from 0.2 to 0.6 instead of 0.5 to 1.0 - const targetAlpha = parseFloat((Math.random() * 0.4 + 0.2).toFixed(1)) - const dx = (Math.random() - 0.5) * 0.1 - const dy = (Math.random() - 0.5) * 0.1 - const magnetism = 0.1 + Math.random() * 4 - return { - x, - y, - translateX, - translateY, - size: pSize, - alpha, - targetAlpha, - dx, - dy, - magnetism, - } - } - - const rgb = hexToRgb(color) - - const drawCircle = (circle: Circle, update = false) => { - if (context.current) { - const { x, y, translateX, translateY, size, alpha } = circle - context.current.translate(translateX, translateY) - context.current.beginPath() - context.current.arc(x, y, size, 0, 2 * Math.PI) - context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})` - context.current.fill() - context.current.setTransform(dpr, 0, 0, dpr, 0, 0) - - if (!update) { - circles.current.push(circle) - } - } - } - - const clearContext = () => { - if (context.current) { - context.current.clearRect( - 0, - 0, - canvasSize.current.w, - canvasSize.current.h - ) - } - } - - const drawParticles = () => { - clearContext() - const particleCount = quantity - for (let i = 0; i < particleCount; i++) { - const circle = circleParams() - drawCircle(circle) - } - } - - const remapValue = ( - value: number, - start1: number, - end1: number, - start2: number, - end2: number - ): number => { - const remapped = - ((value - start1) * (end2 - start2)) / (end1 - start1) + start2 - return remapped > 0 ? remapped : 0 - } - - const animate = () => { - clearContext() - circles.current.forEach((circle: Circle, i: number) => { - // Handle the alpha value - const edge = [ - circle.x + circle.translateX - circle.size, // distance from left edge - canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge - circle.y + circle.translateY - circle.size, // distance from top edge - canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge - ] - const closestEdge = edge.reduce((a, b) => Math.min(a, b)) - const remapClosestEdge = parseFloat( - remapValue(closestEdge, 0, 20, 0, 1).toFixed(2) - ) - if (remapClosestEdge > 1) { - circle.alpha += 0.03 - if (circle.alpha > circle.targetAlpha) { - circle.alpha = circle.targetAlpha - } - } else { - circle.alpha = circle.targetAlpha * remapClosestEdge - } - circle.x += circle.dx + vx - circle.y += circle.dy + vy - circle.translateX += - (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / - ease - circle.translateY += - (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / - ease - - drawCircle(circle, true) - - // circle gets out of the canvas - if ( - circle.x < -circle.size || - circle.x > canvasSize.current.w + circle.size || - circle.y < -circle.size || - circle.y > canvasSize.current.h + circle.size - ) { - // remove the circle from the array - circles.current.splice(i, 1) - // create a new circle - const newCircle = circleParams() - drawCircle(newCircle) - } - }) - rafID.current = window.requestAnimationFrame(animate) - } - - return ( - <div - className={cn("pointer-events-none", className)} - ref={canvasContainerRef} - aria-hidden="true" - {...props} - > - <canvas ref={canvasRef} className="size-full" /> - </div> - ) -} + const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasContainerRef = useRef<HTMLDivElement>(null); + const context = useRef<CanvasRenderingContext2D | null>(null); + const circles = useRef<Circle[]>([]); + const mousePosition = MousePosition(); + const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; + const rafID = useRef<number | null>(null); + const resizeTimeout = useRef<NodeJS.Timeout | null>(null); + + useEffect(() => { + if (canvasRef.current) { + context.current = canvasRef.current.getContext("2d"); + } + initCanvas(); + animate(); + + const handleResize = () => { + if (resizeTimeout.current) { + clearTimeout(resizeTimeout.current); + } + resizeTimeout.current = setTimeout(() => { + initCanvas(); + }, 200); + }; + + window.addEventListener("resize", handleResize); + + return () => { + if (rafID.current != null) { + window.cancelAnimationFrame(rafID.current); + } + if (resizeTimeout.current) { + clearTimeout(resizeTimeout.current); + } + window.removeEventListener("resize", handleResize); + }; + }, [color]); + + useEffect(() => { + onMouseMove(); + }, [mousePosition.x, mousePosition.y]); + + useEffect(() => { + initCanvas(); + }, [refresh]); + + const initCanvas = () => { + resizeCanvas(); + drawParticles(); + }; + + const onMouseMove = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const { w, h } = canvasSize.current; + const x = mousePosition.x - rect.left - w / 2; + const y = mousePosition.y - rect.top - h / 2; + const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; + if (inside) { + mouse.current.x = x; + mouse.current.y = y; + } + } + }; + + const resizeCanvas = () => { + if (canvasContainerRef.current && canvasRef.current && context.current) { + canvasSize.current.w = canvasContainerRef.current.offsetWidth; + canvasSize.current.h = canvasContainerRef.current.offsetHeight; + + canvasRef.current.width = canvasSize.current.w * dpr; + canvasRef.current.height = canvasSize.current.h * dpr; + canvasRef.current.style.width = `${canvasSize.current.w}px`; + canvasRef.current.style.height = `${canvasSize.current.h}px`; + context.current.scale(dpr, dpr); + + // Clear existing particles and create new ones with exact quantity + circles.current = []; + for (let i = 0; i < quantity; i++) { + const circle = circleParams(); + drawCircle(circle); + } + } + }; + + const circleParams = (): Circle => { + const x = Math.floor(Math.random() * canvasSize.current.w); + const y = Math.floor(Math.random() * canvasSize.current.h); + const translateX = 0; + const translateY = 0; + const pSize = Math.floor(Math.random() * 2) + size; + const alpha = 0; + // Lower opacity for better subtlety - range from 0.2 to 0.6 instead of 0.5 to 1.0 + const targetAlpha = Number.parseFloat( + (Math.random() * 0.4 + 0.2).toFixed(1), + ); + const dx = (Math.random() - 0.5) * 0.1; + const dy = (Math.random() - 0.5) * 0.1; + const magnetism = 0.1 + Math.random() * 4; + return { + x, + y, + translateX, + translateY, + size: pSize, + alpha, + targetAlpha, + dx, + dy, + magnetism, + }; + }; + + const rgb = hexToRgb(color); + + const drawCircle = (circle: Circle, update = false) => { + if (context.current) { + const { x, y, translateX, translateY, size, alpha } = circle; + context.current.translate(translateX, translateY); + context.current.beginPath(); + context.current.arc(x, y, size, 0, 2 * Math.PI); + context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; + context.current.fill(); + context.current.setTransform(dpr, 0, 0, dpr, 0, 0); + + if (!update) { + circles.current.push(circle); + } + } + }; + + const clearContext = () => { + if (context.current) { + context.current.clearRect( + 0, + 0, + canvasSize.current.w, + canvasSize.current.h, + ); + } + }; + + const drawParticles = () => { + clearContext(); + const particleCount = quantity; + for (let i = 0; i < particleCount; i++) { + const circle = circleParams(); + drawCircle(circle); + } + }; + + const remapValue = ( + value: number, + start1: number, + end1: number, + start2: number, + end2: number, + ): number => { + const remapped = + ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; + return remapped > 0 ? remapped : 0; + }; + + const animate = () => { + clearContext(); + circles.current.forEach((circle: Circle, i: number) => { + // Handle the alpha value + const edge = [ + circle.x + circle.translateX - circle.size, // distance from left edge + canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge + circle.y + circle.translateY - circle.size, // distance from top edge + canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge + ]; + const closestEdge = edge.reduce((a, b) => Math.min(a, b)); + const remapClosestEdge = Number.parseFloat( + remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), + ); + if (remapClosestEdge > 1) { + circle.alpha += 0.03; + if (circle.alpha > circle.targetAlpha) { + circle.alpha = circle.targetAlpha; + } + } else { + circle.alpha = circle.targetAlpha * remapClosestEdge; + } + circle.x += circle.dx + vx; + circle.y += circle.dy + vy; + circle.translateX += + (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / + ease; + circle.translateY += + (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / + ease; + + drawCircle(circle, true); + + // circle gets out of the canvas + if ( + circle.x < -circle.size || + circle.x > canvasSize.current.w + circle.size || + circle.y < -circle.size || + circle.y > canvasSize.current.h + circle.size + ) { + // remove the circle from the array + circles.current.splice(i, 1); + // create a new circle + const newCircle = circleParams(); + drawCircle(newCircle); + } + }); + rafID.current = window.requestAnimationFrame(animate); + }; + + return ( + <div + className={cn("pointer-events-none", className)} + ref={canvasContainerRef} + aria-hidden="true" + {...props} + > + <canvas ref={canvasRef} className="size-full" /> + </div> + ); +}; diff --git a/src/components/ui/pixelated-voice-overlay.tsx b/src/components/ui/pixelated-voice-overlay.tsx index ba0d277e..91f160ff 100644 --- a/src/components/ui/pixelated-voice-overlay.tsx +++ b/src/components/ui/pixelated-voice-overlay.tsx @@ -257,7 +257,7 @@ const PixelatedVoiceOverlayComponent = ({ "relative w-full max-w-xs rounded-2xl p-4 backdrop-blur-md transition-all duration-300 sm:max-w-sm", beforeCard.className, beforeActive - ? "ring-2 ring-red-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-900 bg-red-50/95 dark:bg-red-900/40 shadow-xl" + ? "bg-red-50/95 shadow-xl ring-2 ring-red-500 ring-offset-2 ring-offset-white dark:bg-red-900/40 dark:ring-offset-slate-900" : inactiveCardClasses, )} > @@ -271,17 +271,20 @@ const PixelatedVoiceOverlayComponent = ({ className={cn( "font-bold text-xs uppercase tracking-[0.28em]", beforeCard.labelClassName, - beforeActive && "text-red-700 dark:text-red-100 drop-shadow-sm", + beforeActive && + "text-red-700 drop-shadow-sm dark:text-red-100", )} > {beforeCard.title} </p> - <p className={cn( - "mt-3 text-sm leading-relaxed sm:text-base", - beforeActive - ? "text-red-900 font-medium dark:text-red-50 drop-shadow-sm" - : "text-slate-700 dark:text-white/90" - )}> + <p + className={cn( + "mt-3 text-sm leading-relaxed sm:text-base", + beforeActive + ? "font-medium text-red-900 drop-shadow-sm dark:text-red-50" + : "text-slate-700 dark:text-white/90", + )} + > {beforeCard.description} </p> </div> @@ -322,7 +325,7 @@ const PixelatedVoiceOverlayComponent = ({ "relative w-full max-w-xs rounded-2xl p-4 backdrop-blur-md transition-all duration-300 sm:max-w-sm", afterCard.className, afterActive - ? "ring-2 ring-emerald-400 ring-offset-2 ring-offset-white dark:ring-offset-slate-900 bg-emerald-50/95 dark:bg-emerald-900/40 shadow-xl" + ? "bg-emerald-50/95 shadow-xl ring-2 ring-emerald-400 ring-offset-2 ring-offset-white dark:bg-emerald-900/40 dark:ring-offset-slate-900" : inactiveCardClasses, )} > @@ -340,17 +343,20 @@ const PixelatedVoiceOverlayComponent = ({ className={cn( "font-bold text-xs uppercase tracking-[0.28em]", afterCard.labelClassName, - afterActive && "text-emerald-700 dark:text-emerald-100 drop-shadow-sm", + afterActive && + "text-emerald-700 drop-shadow-sm dark:text-emerald-100", )} > {afterCard.title} </p> - <p className={cn( - "mt-3 text-sm leading-relaxed sm:text-base", - afterActive - ? "text-emerald-900 font-medium dark:text-emerald-50 drop-shadow-sm" - : "text-slate-700 dark:text-white/90" - )}> + <p + className={cn( + "mt-3 text-sm leading-relaxed sm:text-base", + afterActive + ? "font-medium text-emerald-900 drop-shadow-sm dark:text-emerald-50" + : "text-slate-700 dark:text-white/90", + )} + > {afterCard.description} </p> </div> @@ -477,7 +483,7 @@ const PixelatedVoiceOverlayComponent = ({ </div> {!hasActiveTrack ? ( <div className="flex flex-col gap-3 text-center text-slate-600 sm:flex-row sm:items-center sm:justify-between sm:text-left dark:text-white/70"> - <div className="flex flex-col gap-2 items-center sm:items-start"> + <div className="flex flex-col items-center gap-2 sm:items-start"> <span className="text-slate-500 text-xs uppercase tracking-[0.28em] dark:text-white/50"> Audio Comparison </span> @@ -486,7 +492,7 @@ const PixelatedVoiceOverlayComponent = ({ timbre, pacing, and emotion. </p> </div> - <div className="flex flex-col gap-2 sm:flex-row sm:items-center items-center"> + <div className="flex flex-col items-center gap-2 sm:flex-row sm:items-center"> <button type="button" onClick={isPlaying ? onStop : onPlay} diff --git a/src/data/closers/mockClosers.ts b/src/data/closers/mockClosers.ts index ba63e088..a4c9adcf 100644 --- a/src/data/closers/mockClosers.ts +++ b/src/data/closers/mockClosers.ts @@ -22,7 +22,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-1", name: "Sarah Martinez", title: "Senior Real Estate Closer", - image: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop&crop=face", rating: 4.9, reviews: 127, dealsClosed: 523, @@ -40,7 +41,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-2", name: "Michael Chen", title: "Commercial Real Estate Specialist", - image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop&crop=face", rating: 4.8, reviews: 89, dealsClosed: 312, @@ -58,7 +60,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-3", name: "Jessica Thompson", title: "Wholesale Deal Closer", - image: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400&h=400&fit=crop&crop=face", rating: 5.0, reviews: 203, dealsClosed: 892, @@ -76,7 +79,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-4", name: "David Rodriguez", title: "Residential Closer", - image: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=400&h=400&fit=crop&crop=face", rating: 4.7, reviews: 156, dealsClosed: 445, @@ -94,7 +98,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-5", name: "Amanda Foster", title: "Investment Property Specialist", - image: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=400&h=400&fit=crop&crop=face", rating: 4.9, reviews: 178, dealsClosed: 634, @@ -107,7 +112,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-6", name: "James Wilson", title: "Multi-Family Closer", - image: "https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=400&h=400&fit=crop&crop=face", rating: 4.8, reviews: 94, dealsClosed: 287, @@ -120,7 +126,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-7", name: "Rachel Kim", title: "Fix & Flip Closer", - image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&h=400&fit=crop&crop=face", rating: 4.9, reviews: 142, dealsClosed: 567, @@ -133,7 +140,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-8", name: "Robert Taylor", title: "Land Acquisition Specialist", - image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop&crop=face", rating: 4.8, reviews: 76, dealsClosed: 234, @@ -146,7 +154,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-9", name: "Maria Garcia", title: "Relocation Closer", - image: "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=400&h=400&fit=crop&crop=face", rating: 4.9, reviews: 165, dealsClosed: 489, @@ -159,7 +168,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-10", name: "Kevin Patel", title: "Wholesale Transaction Expert", - image: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=400&h=400&fit=crop&crop=face", rating: 5.0, reviews: 201, dealsClosed: 756, @@ -177,7 +187,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-11", name: "Emily Johnson", title: "Luxury Property Closer", - image: "https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=400&h=400&fit=crop&crop=face", rating: 4.9, reviews: 98, dealsClosed: 267, @@ -190,7 +201,8 @@ export const mockClosers: CloserProfile[] = [ id: "closer-12", name: "Thomas Anderson", title: "Portfolio Closer", - image: "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=400&h=400&fit=crop&crop=face", + image: + "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=400&h=400&fit=crop&crop=face", rating: 4.8, reviews: 87, dealsClosed: 312, @@ -200,4 +212,3 @@ export const mockClosers: CloserProfile[] = [ hourlyRate: 195, }, ]; - diff --git a/src/data/contact/closer.ts b/src/data/contact/closer.ts index e69e0f62..6d2e01b5 100644 --- a/src/data/contact/closer.ts +++ b/src/data/contact/closer.ts @@ -8,9 +8,7 @@ export const closerFormSchema = z.object({ realEstateLicense: z .string() .min(1, { message: "Real estate license number is required." }), - licenseState: z - .string() - .min(2, { message: "License state is required." }), + licenseState: z.string().min(2, { message: "License state is required." }), yearsExperience: z .string() .min(1, { message: "Please specify years of experience." }), @@ -20,11 +18,10 @@ export const closerFormSchema = z.object({ availability: z .string() .min(1, { message: "Please specify your availability." }), - whyApply: z - .string() - .min(50, { - message: "Please provide at least 50 characters explaining why you want to become a closer.", - }), + whyApply: z.string().min(50, { + message: + "Please provide at least 50 characters explaining why you want to become a closer.", + }), portfolioUrl: z.string().url().optional().or(z.literal("")), termsAccepted: z.boolean().refine((val) => val === true, { message: "You must accept the terms and conditions.", @@ -193,7 +190,3 @@ export const closerFormFields: Array<{ required: true, }, ]; - - - - diff --git a/src/data/products/closers.ts b/src/data/products/closers.ts index 14273848..978b326d 100644 --- a/src/data/products/closers.ts +++ b/src/data/products/closers.ts @@ -1,9 +1,9 @@ +import { mockClosers } from "@/data/closers/mockClosers"; import { LicenseType, ProductCategory, type ProductType, } from "@/types/products"; -import { mockClosers } from "@/data/closers/mockClosers"; // Get top 3 closers (sorted by rating, then deals closed) const getTopClosers = () => { @@ -21,7 +21,7 @@ const getTopClosers = () => { const topClosers = getTopClosers(); // Convert closer to product -const closerToProduct = (closer: typeof mockClosers[0]): ProductType => { +const closerToProduct = (closer: (typeof mockClosers)[0]): ProductType => { return { id: `closer-${closer.id}`, name: `${closer.name} - ${closer.title}`, @@ -72,7 +72,9 @@ export const closerProducts: ProductType[] = [ ProductCategory.Monetize, ProductCategory.AddOn, ], - images: ["https://images.unsplash.com/photo-1560250097-0b93528c311a?w=1200&h=800&fit=crop&q=80"], + images: [ + "https://images.unsplash.com/photo-1560250097-0b93528c311a?w=1200&h=800&fit=crop&q=80", + ], types: [], reviews: [], colors: [], @@ -103,4 +105,3 @@ export const closerProducts: ProductType[] = [ // Add top 3 closers as individual product cards ...topClosers.map(closerToProduct), ]; - diff --git a/src/data/products/monetize.ts b/src/data/products/monetize.ts index 47a10bd3..dd2a2ee3 100644 --- a/src/data/products/monetize.ts +++ b/src/data/products/monetize.ts @@ -18,11 +18,10 @@ export const monetizeProducts: ProductType[] = [ licenseName: LicenseType.Proprietary, description: "Publish and sell your proven sales scripts to thousands of Deal Scale operators. Share your best cadences, email sequences, and messaging templates to earn recurring revenue.", - categories: [ - ProductCategory.SalesScripts, - ProductCategory.Monetize, + categories: [ProductCategory.SalesScripts, ProductCategory.Monetize], + images: [ + "https://images.unsplash.com/photo-1551434678-e076c223a692?w=1200&h=800&fit=crop&q=80", ], - images: ["https://images.unsplash.com/photo-1551434678-e076c223a692?w=1200&h=800&fit=crop&q=80"], types: [], reviews: [], colors: [], @@ -49,11 +48,10 @@ export const monetizeProducts: ProductType[] = [ licenseName: LicenseType.Proprietary, description: "Monetize your automation workflows by sharing them with the Deal Scale community. Sell your proven automations and earn revenue from thousands of operators who need your solutions.", - categories: [ - ProductCategory.Workflows, - ProductCategory.Monetize, + categories: [ProductCategory.Workflows, ProductCategory.Monetize], + images: [ + "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1200&h=800&fit=crop&q=80", ], - images: ["https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1200&h=800&fit=crop&q=80"], types: [], reviews: [], colors: [], @@ -80,11 +78,10 @@ export const monetizeProducts: ProductType[] = [ licenseName: LicenseType.Proprietary, description: "Monetize your voice agents and AI concierges by listing them on Deal Scale. Deploy your voice automation solutions to our network of clients and earn recurring revenue.", - categories: [ - ProductCategory.Voices, - ProductCategory.Monetize, + categories: [ProductCategory.Voices, ProductCategory.Monetize], + images: [ + "https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?w=1200&h=800&fit=crop&q=80", ], - images: ["https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?w=1200&h=800&fit=crop&q=80"], types: [], reviews: [], colors: [], @@ -103,7 +100,3 @@ export const monetizeProducts: ProductType[] = [ ], }, ]; - - - - diff --git a/src/hooks/useExitIntent.ts b/src/hooks/useExitIntent.ts index 89ec3333..1ef119ce 100644 --- a/src/hooks/useExitIntent.ts +++ b/src/hooks/useExitIntent.ts @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import { safeSessionStorageGetItem, safeSessionStorageSetItem, } from "@/utils/storage/safeStorage"; +import { useEffect, useRef, useState } from "react"; export interface UseExitIntentOptions { /** diff --git a/src/lib/constants/z-index.ts b/src/lib/constants/z-index.ts index e2073fcd..0692620f 100644 --- a/src/lib/constants/z-index.ts +++ b/src/lib/constants/z-index.ts @@ -30,8 +30,3 @@ export const Z_INDEX = { MOBILE_MENU: 9999, CRITICAL_OVERLAY: 9999, } as const; - - - - - diff --git a/src/lib/utils/cookies.ts b/src/lib/utils/cookies.ts index 5f4b52b5..46a646b7 100644 --- a/src/lib/utils/cookies.ts +++ b/src/lib/utils/cookies.ts @@ -28,8 +28,7 @@ function getCookie(name: string): string | null { for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) === " ") c = c.substring(1, c.length); - if (c.indexOf(nameEQ) === 0) - return c.substring(nameEQ.length, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); } return null; } @@ -60,6 +59,3 @@ export function clearBannerClosed(): void { if (typeof document === "undefined") return; document.cookie = `${BANNER_CLOSED_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; } - - - diff --git a/src/pages/api/rss/connect-cre.xml.ts b/src/pages/api/rss/connect-cre.xml.ts index 0fc3676f..c9904e78 100644 --- a/src/pages/api/rss/connect-cre.xml.ts +++ b/src/pages/api/rss/connect-cre.xml.ts @@ -30,9 +30,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching Connect CRE RSS feed:", error); - res.status(502).send( - '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Connect CRE Feed ErrorConnect CRE RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'Connect CRE Feed ErrorConnect CRE RSS temporarily unavailable.', + ); } } - diff --git a/src/pages/api/rss/first-tuesday.xml.ts b/src/pages/api/rss/first-tuesday.xml.ts index ca7244af..2649ac77 100644 --- a/src/pages/api/rss/first-tuesday.xml.ts +++ b/src/pages/api/rss/first-tuesday.xml.ts @@ -31,9 +31,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching first tuesday RSS feed:", error); - res.status(502).send( - 'first tuesday Feed Errorfirst tuesday RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'first tuesday Feed Errorfirst tuesday RSS temporarily unavailable.', + ); } } - diff --git a/src/pages/api/rss/github.xml.ts b/src/pages/api/rss/github.xml.ts index 59af8292..dc18f50d 100644 --- a/src/pages/api/rss/github.xml.ts +++ b/src/pages/api/rss/github.xml.ts @@ -35,6 +35,3 @@ export default async function handler( ); } } - - - diff --git a/src/pages/api/rss/hybrid-private.xml.ts b/src/pages/api/rss/hybrid-private.xml.ts index ea44f0fc..4e0d418c 100644 --- a/src/pages/api/rss/hybrid-private.xml.ts +++ b/src/pages/api/rss/hybrid-private.xml.ts @@ -575,4 +575,3 @@ export default async function handler( res.status(502).send(errorFeed); } } - diff --git a/src/pages/api/rss/hybrid.xml.ts b/src/pages/api/rss/hybrid.xml.ts index 0410861a..a911634a 100644 --- a/src/pages/api/rss/hybrid.xml.ts +++ b/src/pages/api/rss/hybrid.xml.ts @@ -303,9 +303,7 @@ const buildChannelXml = (entries: HybridEntry[]): string => { ? "https://www.youtube.com/@DealScaleRealEstate" : `${SITE_URL}/blog`; const sourceName = - entry.source === "youtube" - ? "DealScale YouTube" - : "DealScale Blog"; + entry.source === "youtube" ? "DealScale YouTube" : "DealScale Blog"; return ` ${sanitize(entry.title)} @@ -373,16 +371,15 @@ export default async function handler( }; try { - const [beehiivResult, youtubeResult] = - await Promise.allSettled([ - fetch(BEEHIIV_FEED, { - headers: { - "User-Agent": "DealScaleHybridRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetchYouTubeFeed(), - ]); + const [beehiivResult, youtubeResult] = await Promise.allSettled([ + fetch(BEEHIIV_FEED, { + headers: { + "User-Agent": "DealScaleHybridRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetchYouTubeFeed(), + ]); const beehiivXml = beehiivResult.status === "fulfilled" && beehiivResult.value.ok @@ -416,10 +413,7 @@ export default async function handler( `Feed entries: Beehiiv=${beehiivEntries.length}, YouTube=${youtubeEntries.length}`, ); - const combinedEntries = [ - ...beehiivEntries, - ...youtubeEntries, - ].sort( + const combinedEntries = [...beehiivEntries, ...youtubeEntries].sort( (a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(), ); diff --git a/src/pages/api/rss/inman.xml.ts b/src/pages/api/rss/inman.xml.ts index 3093b160..cd14cf5c 100644 --- a/src/pages/api/rss/inman.xml.ts +++ b/src/pages/api/rss/inman.xml.ts @@ -26,9 +26,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching Inman News RSS feed:", error); - res.status(502).send( - 'Inman News Feed ErrorInman News RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'Inman News Feed ErrorInman News RSS temporarily unavailable.', + ); } } - diff --git a/src/pages/api/rss/nar.xml.ts b/src/pages/api/rss/nar.xml.ts index ba8cade7..140a4ecd 100644 --- a/src/pages/api/rss/nar.xml.ts +++ b/src/pages/api/rss/nar.xml.ts @@ -4,8 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; // The provided URL is a page listing feeds, not the actual feed // Common pattern: https://www.nar.realtor/feed or https://www.nar.realtor/rss // NAR may have multiple feeds (news, magazine, etc.) - choose the most relevant -const NAR_FEED = - process.env.NAR_RSS_FEED_URL || "https://www.nar.realtor/feed"; +const NAR_FEED = process.env.NAR_RSS_FEED_URL || "https://www.nar.realtor/feed"; const CACHE_CONTROL = "s-maxage=900, stale-while-revalidate=3600"; export default async function handler( @@ -31,9 +30,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching NAR RSS feed:", error); - res.status(502).send( - 'NAR Feed ErrorNAR RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'NAR Feed ErrorNAR RSS temporarily unavailable.', + ); } } - diff --git a/src/pages/api/rss/news.xml.ts b/src/pages/api/rss/news.xml.ts index f6bbd9b8..ff923cbf 100644 --- a/src/pages/api/rss/news.xml.ts +++ b/src/pages/api/rss/news.xml.ts @@ -81,10 +81,7 @@ type RSSParsed = { }; }; -const buildRSSEntries = ( - feedXml: string, - sourceName: string, -): NewsEntry[] => { +const buildRSSEntries = (feedXml: string, sourceName: string): NewsEntry[] => { try { const parsed = parser.parse(feedXml) as RSSParsed; @@ -180,45 +177,51 @@ export default async function handler( res: NextApiResponse, ) { try { - const [inmanResult, reitResult, connectCreResult, wpjResult, ftResult, narResult] = - await Promise.allSettled([ - fetch(INMAN_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetch(REIT_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetch(CONNECT_CRE_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetch(WORLD_PROPERTY_JOURNAL_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetch(FIRST_TUESDAY_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - fetch(NAR_FEED, { - headers: { - "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", - Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", - }, - }), - ]); + const [ + inmanResult, + reitResult, + connectCreResult, + wpjResult, + ftResult, + narResult, + ] = await Promise.allSettled([ + fetch(INMAN_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetch(REIT_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetch(CONNECT_CRE_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetch(WORLD_PROPERTY_JOURNAL_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetch(FIRST_TUESDAY_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + fetch(NAR_FEED, { + headers: { + "User-Agent": "DealScaleNewsRSSProxy/1.0 (+https://dealscale.io)", + Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + }, + }), + ]); const inmanXml = inmanResult.status === "fulfilled" && inmanResult.value.ok @@ -245,7 +248,9 @@ export default async function handler( ? await narResult.value.text() : ""; - const inmanEntries = inmanXml ? buildRSSEntries(inmanXml, "Inman News") : []; + const inmanEntries = inmanXml + ? buildRSSEntries(inmanXml, "Inman News") + : []; const reitEntries = reitXml ? buildRSSEntries(reitXml, "REIT.com") : []; const connectCreEntries = connectCreXml ? buildRSSEntries(connectCreXml, "Connect CRE") @@ -253,9 +258,7 @@ export default async function handler( const wpjEntries = wpjXml ? buildRSSEntries(wpjXml, "World Property Journal") : []; - const ftEntries = ftXml - ? buildRSSEntries(ftXml, "first tuesday") - : []; + const ftEntries = ftXml ? buildRSSEntries(ftXml, "first tuesday") : []; const narEntries = narXml ? buildRSSEntries(narXml, "NAR") : []; console.log( @@ -327,4 +330,3 @@ export default async function handler( res.status(502).send(errorFeed); } } - diff --git a/src/pages/api/rss/reit.xml.ts b/src/pages/api/rss/reit.xml.ts index 809a5c50..8157487a 100644 --- a/src/pages/api/rss/reit.xml.ts +++ b/src/pages/api/rss/reit.xml.ts @@ -3,8 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; // TODO: Update with actual REIT.com RSS feed URL // The provided URL is a page listing feeds, not the actual feed // Common pattern: https://www.reit.com/feed or https://www.reit.com/rss -const REIT_FEED = - process.env.REIT_RSS_FEED_URL || "https://www.reit.com/feed"; +const REIT_FEED = process.env.REIT_RSS_FEED_URL || "https://www.reit.com/feed"; const CACHE_CONTROL = "s-maxage=900, stale-while-revalidate=3600"; export default async function handler( @@ -30,9 +29,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching REIT.com RSS feed:", error); - res.status(502).send( - 'REIT.com Feed ErrorREIT.com RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'REIT.com Feed ErrorREIT.com RSS temporarily unavailable.', + ); } } - diff --git a/src/pages/api/rss/world-property-journal.xml.ts b/src/pages/api/rss/world-property-journal.xml.ts index 76fa3852..bedfcccc 100644 --- a/src/pages/api/rss/world-property-journal.xml.ts +++ b/src/pages/api/rss/world-property-journal.xml.ts @@ -29,9 +29,10 @@ export default async function handler( res.status(200).send(xml); } catch (error) { console.error("Error fetching World Property Journal RSS feed:", error); - res.status(502).send( - 'World Property Journal Feed ErrorWorld Property Journal RSS temporarily unavailable.', - ); + res + .status(502) + .send( + 'World Property Journal Feed ErrorWorld Property Journal RSS temporarily unavailable.', + ); } } - diff --git a/src/stores/useDataModuleStore.ts b/src/stores/useDataModuleStore.ts index 7ad5d081..23456e61 100644 --- a/src/stores/useDataModuleStore.ts +++ b/src/stores/useDataModuleStore.ts @@ -470,10 +470,7 @@ export function useDataModule>( const loadPromise = store.getState().load(); if (loadPromise) { void loadPromise.catch((error) => { - console.error( - `[useDataModule] Load error for key "${key}":`, - error, - ); + console.error(`[useDataModule] Load error for key "${key}":`, error); console.error( `[useDataModule] Load error stack:`, error instanceof Error ? error.stack : String(error), diff --git a/src/utils/csvParser.ts b/src/utils/csvParser.ts index 9c94efff..1e4c5ca5 100644 --- a/src/utils/csvParser.ts +++ b/src/utils/csvParser.ts @@ -41,9 +41,7 @@ export async function parseCSV(file: File): Promise { if (hasData) { contacts.push(contact); } else { - errors.push( - `Row with no data: ${JSON.stringify(row)}`, - ); + errors.push(`Row with no data: ${JSON.stringify(row)}`); } } @@ -95,18 +93,14 @@ export async function parseExcel(file: File): Promise { if (hasData) { contacts.push(contact); } else { - errors.push( - `Row with no data: ${JSON.stringify(row)}`, - ); + errors.push(`Row with no data: ${JSON.stringify(row)}`); } } resolve({ contacts, errors }); } catch (error) { errors.push( - error instanceof Error - ? error.message - : "Failed to parse Excel file", + error instanceof Error ? error.message : "Failed to parse Excel file", ); resolve({ contacts, errors }); } @@ -138,7 +132,9 @@ export async function parseContactFile(file: File): Promise { return { contacts: [], - errors: [`Unsupported file type: ${fileExtension}. Please use CSV or Excel files.`], + errors: [ + `Unsupported file type: ${fileExtension}. Please use CSV or Excel files.`, + ], }; } diff --git a/src/utils/seo/schema/roadmap.ts b/src/utils/seo/schema/roadmap.ts index f692be6e..87247bb8 100644 --- a/src/utils/seo/schema/roadmap.ts +++ b/src/utils/seo/schema/roadmap.ts @@ -2,7 +2,7 @@ import type { FeatureTimelineMilestone } from "@/components/features/FeatureTime import { companyData } from "@/data/company"; import { defaultSeo } from "@/utils/seo/staticSeo"; -import { buildAbsoluteUrl, ORGANIZATION_ID, SCHEMA_CONTEXT } from "./helpers"; +import { ORGANIZATION_ID, SCHEMA_CONTEXT, buildAbsoluteUrl } from "./helpers"; export interface BuildRoadmapSchemaOptions { url?: string; diff --git a/tools/validate-sitemap.ts b/tools/validate-sitemap.ts new file mode 100644 index 00000000..39b1b21f --- /dev/null +++ b/tools/validate-sitemap.ts @@ -0,0 +1,264 @@ +#!/usr/bin/env tsx +/** + * Local sitemap validation script + * Validates the sitemap before deployment to catch errors early + */ + +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// Import the sitemap function +import sitemap from "../src/app/sitemap"; + +interface ValidationError { + type: "error" | "warning"; + message: string; + url?: string; + line?: number; +} + +interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + totalUrls: number; + externalUrls: string[]; + canonicalHost: string; +} + +/** + * Extract hostname from URL + */ +function getHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} + +/** + * Validate sitemap entries + */ +async function validateSitemap(): Promise { + const errors: ValidationError[] = []; + const externalUrls: string[] = []; + + // Generate sitemap + console.log("📋 Generating sitemap..."); + const entries = await sitemap(); + + // Get canonical host + const canonicalHost = entries[0]?.url ? getHost(entries[0].url) : ""; + + console.log(`✅ Generated ${entries.length} sitemap entries`); + + // Validate each entry + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const line = i + 1; + + if (!entry.url) { + errors.push({ + type: "error", + message: "Entry missing URL", + line, + }); + continue; + } + + // Check for external URLs + const entryHost = getHost(entry.url); + if (entryHost && entryHost !== canonicalHost) { + externalUrls.push(entry.url); + errors.push({ + type: "error", + message: `External URL not allowed in sitemap: ${entry.url}`, + url: entry.url, + line, + }); + } + + // Validate URL format + try { + new URL(entry.url); + } catch { + errors.push({ + type: "error", + message: `Invalid URL format: ${entry.url}`, + url: entry.url, + line, + }); + } + + // Validate priority + if (entry.priority !== undefined) { + if (entry.priority < 0 || entry.priority > 1) { + errors.push({ + type: "warning", + message: `Priority should be between 0.0 and 1.0: ${entry.priority}`, + url: entry.url, + line, + }); + } + } + + // Validate changeFrequency + const validFrequencies = [ + "always", + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "never", + ]; + if ( + entry.changeFrequency && + !validFrequencies.includes(entry.changeFrequency) + ) { + errors.push({ + type: "warning", + message: `Invalid changeFrequency: ${entry.changeFrequency}`, + url: entry.url, + line, + }); + } + } + + // Check for duplicate URLs + const urlSet = new Set(); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.url) { + if (urlSet.has(entry.url)) { + errors.push({ + type: "error", + message: `Duplicate URL: ${entry.url}`, + url: entry.url, + line: i + 1, + }); + } + urlSet.add(entry.url); + } + } + + const valid = errors.filter((e) => e.type === "error").length === 0; + + return { + valid, + errors, + totalUrls: entries.length, + externalUrls, + canonicalHost, + }; +} + +/** + * Generate XML sitemap for inspection + */ +async function generateXmlSitemap(): Promise { + const entries = await sitemap(); + + const xml = ` + +${entries + .map((entry) => { + const url = entry.url || ""; + const lastmod = entry.lastModified + ? entry.lastModified.toISOString().split("T")[0] + : new Date().toISOString().split("T")[0]; + const changefreq = entry.changeFrequency || "weekly"; + const priority = entry.priority ?? 0.5; + + return ` + ${escapeXml(url)} + ${lastmod} + ${changefreq} + ${priority} + `; + }) + .join("\n")} +`; + + return xml; +} + +/** + * Escape XML special characters + */ +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Main validation function + */ +async function main() { + console.log("🔍 Validating sitemap locally...\n"); + + const result = await validateSitemap(); + + // Print results + console.log("\n📊 Validation Results:"); + console.log(` Total URLs: ${result.totalUrls}`); + console.log(` Canonical Host: ${result.canonicalHost}`); + console.log(` External URLs Found: ${result.externalUrls.length}`); + + if (result.errors.length > 0) { + console.log(`\n❌ Found ${result.errors.length} issue(s):\n`); + + const errors = result.errors.filter((e) => e.type === "error"); + const warnings = result.errors.filter((e) => e.type === "warning"); + + if (errors.length > 0) { + console.log("🚨 ERRORS:"); + for (const error of errors) { + console.log( + ` [Line ${error.line || "?"}] ${error.message}${ + error.url ? `\n URL: ${error.url}` : "" + }`, + ); + } + } + + if (warnings.length > 0) { + console.log("\n⚠️ WARNINGS:"); + for (const warning of warnings) { + console.log( + ` [Line ${warning.line || "?"}] ${warning.message}${ + warning.url ? `\n URL: ${warning.url}` : "" + }`, + ); + } + } + } else { + console.log("\n✅ No validation errors found!"); + } + + // Generate XML file for inspection + const xml = await generateXmlSitemap(); + const outputPath = join(process.cwd(), "sitemap-local.xml"); + writeFileSync(outputPath, xml, "utf-8"); + console.log(`\n📄 Generated XML sitemap: ${outputPath}`); + + // Exit with error code if validation failed + if (!result.valid) { + console.log( + "\n❌ Sitemap validation failed. Please fix errors before deploying.", + ); + process.exit(1); + } + + console.log("\n✅ Sitemap is valid and ready for deployment!"); +} + +// Run validation +main().catch((error) => { + console.error("❌ Validation script failed:", error); + process.exit(1); +});