diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6d8274 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_ADMIN_DOMAIN=http://localhost:3000 +VITE_ADMIN_API_TOKEN=cfp_YOUR_TOKEN diff --git a/.gitignore b/.gitignore index bcf5e33..d73dbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist-ssr .env .env.* +!.env.example # TanStack Start / build output .output diff --git a/bun.lock b/bun.lock index 032cae6..28c2ed4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@radix-ui/react-slot": "^1.3.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", + "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", "@tanstack/react-start": "latest", @@ -294,8 +295,12 @@ "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.9", "", { "dependencies": { "@tanstack/devtools": "0.13.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.18", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.0" }, "peerDependencies": { "@tanstack/react-router": "^1.170.0", "@tanstack/router-core": "^1.170.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w=="], diff --git a/package.json b/package.json index 7743d39..fe06b0d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-slot": "^1.3.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", + "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", "@tanstack/react-start": "latest", diff --git a/src/components/sections/Testimonials.tsx b/src/components/sections/Testimonials.tsx index f557b99..2b2f811 100644 --- a/src/components/sections/Testimonials.tsx +++ b/src/components/sections/Testimonials.tsx @@ -1,30 +1,21 @@ import { Quote } from 'lucide-react'; +import type { ClientFeedback } from '#/lib/client-feedback.functions.ts'; + import { Reveal } from '../ui/Reveal'; import { Section } from '../ui/Section'; -const testimonials = [ - { - quote: - "An exceptional engineer who doesn't just write code, but solves complex business problems. Delivered our entire cloud infrastructure ahead of schedule.", - author: 'Sarah J.', - role: 'CTO, TechStartup', - }, - { - quote: - 'Transformed our legacy monolith into a performant, scalable Next.js and Node.js architecture. The performance gains were immediate.', - author: 'Michael T.', - role: 'Engineering Manager', - }, - { - quote: - 'Clean code, excellent communication, and a deep understanding of modern SaaS architecture. Highly recommended for any serious technical project.', - author: 'Elena R.', - role: 'Founder', - }, -]; +type TestimonialsProps = { + testimonials: ClientFeedback[]; +}; + +function formatAuthorRole(testimonial: ClientFeedback) { + return [testimonial.authorRole, testimonial.authorCompany] + .filter(Boolean) + .join(', '); +} -export function Testimonials() { +export function Testimonials({ testimonials }: TestimonialsProps) { return (
@@ -40,7 +31,7 @@ export function Testimonials() {
{testimonials.map((t, i) => ( - +

@@ -48,11 +39,13 @@ export function Testimonials() {

- {t.author.charAt(0)} + {t.authorName.charAt(0)}
-
{t.author}
-
{t.role}
+
{t.authorName}
+
+ {formatAuthorRole(t)} +
diff --git a/src/lib/client-feedback.functions.ts b/src/lib/client-feedback.functions.ts new file mode 100644 index 0000000..8668bd6 --- /dev/null +++ b/src/lib/client-feedback.functions.ts @@ -0,0 +1,50 @@ +import { createServerFn } from '@tanstack/react-start'; + +const CLIENT_FEEDBACK_PATH = '/api/client-feedback'; + +export type ClientFeedback = { + id: string; + quote: string; + authorName: string; + authorRole: string; + authorCompany: string; + createdAt: string; + updatedAt: string; +}; + +type ClientFeedbackResponse = { + data: ClientFeedback[]; + page: number; + limit: number; + total: number; +}; + +export const getClientFeedback = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const baseUrl = import.meta.env.VITE_ADMIN_DOMAIN; + const token = import.meta.env.VITE_ADMIN_API_TOKEN; + + if (!baseUrl || !token) { + return []; + } + + const url = new URL(CLIENT_FEEDBACK_PATH, baseUrl); + url.searchParams.set('page', '1'); + url.searchParams.set('limit', '20'); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch client feedback: ${response.status} ${response.statusText}`, + ); + } + + const payload = (await response.json()) as ClientFeedbackResponse; + return payload.data; + }, +); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 0000000..5d90307 --- /dev/null +++ b/src/lib/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + }, + }, + }); +} diff --git a/src/lib/router-context.ts b/src/lib/router-context.ts new file mode 100644 index 0000000..776121f --- /dev/null +++ b/src/lib/router-context.ts @@ -0,0 +1,5 @@ +import type { QueryClient } from '@tanstack/react-query'; + +export interface RouterContext { + queryClient: QueryClient; +} diff --git a/src/router.tsx b/src/router.tsx index 7f60c53..545caf0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,9 +1,20 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router'; + +import { createQueryClient } from '#/lib/query-client.ts'; +import type { RouterContext } from '#/lib/router-context.ts'; + import { routeTree } from './routeTree.gen'; +export type { RouterContext }; + export function getRouter() { + const queryClient = createQueryClient(); + const router = createTanStackRouter({ routeTree, + context: { + queryClient, + }, scrollRestoration: true, defaultPreload: 'intent', defaultPreloadStaleTime: 0, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 8fd7a52..8c41acf 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,9 @@ -import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { + HeadContent, + Scripts, + createRootRouteWithContext, +} from '@tanstack/react-router'; import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'; import { TanStackDevtools } from '@tanstack/react-devtools'; @@ -6,8 +11,9 @@ import appCss from '../styles.css?url'; import Navigation from '#/components/layout/Navigation.tsx'; import { siteConfig } from '#/config/site.ts'; import { buildStructuredDataScripts } from '#/lib/seo.ts'; +import type { RouterContext } from '#/lib/router-context.ts'; -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ { charSet: 'utf-8' }, @@ -45,25 +51,29 @@ export const Route = createRootRoute({ }); function RootDocument({ children }: { children: React.ReactNode }) { + const { queryClient } = Route.useRouteContext(); + return ( - - {children} - , - }, - ]} - /> + + + {children} + , + }, + ]} + /> + diff --git a/src/routes/index.tsx b/src/routes/index.tsx index cc5b3e9..527767a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router'; import { seo } from '#/lib/seo.ts'; +import { getClientFeedback } from '#/lib/client-feedback.functions.ts'; import { HeroSection } from '#/components/sections/HeroSection.tsx'; import { TrustStrip } from '#/components/sections/TrustStrip.tsx'; import { Metrics } from '#/components/sections/Metrics.tsx'; @@ -14,6 +15,11 @@ import { Contact } from '#/components/sections/Contact.tsx'; import { Footer } from '#/components/sections/Footer.tsx'; export const Route = createFileRoute('/')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData({ + queryKey: ['client-feedback'], + queryFn: () => getClientFeedback(), + }), head: () => { const { meta, links } = seo({ url: '/' }); @@ -23,6 +29,8 @@ export const Route = createFileRoute('/')({ }); function Home() { + const testimonials = Route.useLoaderData(); + return (
@@ -33,7 +41,7 @@ function Home() { - +