feat: Introduce site configuration management with a new Prisma model and API endpoints, and add a health check endpoint.

This commit is contained in:
Moh Dzulfikri Maulana
2026-03-10 01:27:17 +07:00
parent 5b45c32109
commit da231dd779
8 changed files with 245 additions and 103 deletions

View File

@@ -70,6 +70,7 @@
"@prisma/client", "@prisma/client",
"@prisma/engines", "@prisma/engines",
"bcrypt", "bcrypt",
"esbuild",
"prisma", "prisma",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"

View File

@@ -105,3 +105,15 @@ model Tag {
blogs Blog[] blogs Blog[]
} }
model SiteConfig {
id String @id @default(cuid())
cvUrl String?
linkedinUrl String?
githubUrl String?
twitterUrl String?
heroTitle String?
heroSubtitle String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -1,7 +1,13 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { format } from "date-fns"; import { format } from "date-fns";
import Link from "next/link"; import Link from "next/link";
import { Calendar, Clock, ArrowRight, Tag as TagIcon, Search } from "lucide-react"; import {
Calendar,
Clock,
ArrowRight,
Tag as TagIcon,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,12 +40,16 @@ export default async function BlogPage() {
Blog<span className="text-primary">.</span> Blog<span className="text-primary">.</span>
</h1> </h1>
<p className="max-w-xl text-lg text-foreground-muted leading-relaxed"> <p className="max-w-xl text-lg text-foreground-muted leading-relaxed">
Exploring the world of fullstack development, architecture, and developer productivity. Sharing what I learn along the way. Exploring the world of fullstack development, architecture, and
developer productivity. Sharing what I learn along the way.
</p> </p>
<div className="mt-4 flex flex-col md:flex-row gap-4 items-center"> <div className="mt-4 flex flex-col md:flex-row gap-4 items-center">
<div className="relative w-full max-w-md group"> <div className="relative w-full max-w-md group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted group-focus-within:text-primary transition-colors" size={18} /> <Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-muted group-focus-within:text-primary transition-colors"
size={18}
/>
<input <input
type="text" type="text"
placeholder="Search articles..." placeholder="Search articles..."
@@ -61,8 +71,13 @@ export default async function BlogPage() {
<Clock className="text-primary/40" size={32} /> <Clock className="text-primary/40" size={32} />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-xl font-display font-semibold text-foreground">No publications yet</h3> <h3 className="text-xl font-display font-semibold text-foreground">
<p className="text-foreground-muted max-w-xs">Writing takes time. Check back soon for new insights and stories.</p> No publications yet
</h3>
<p className="text-foreground-muted max-w-xs">
Writing takes time. Check back soon for new insights and
stories.
</p>
</div> </div>
</div> </div>
)} )}
@@ -82,11 +97,12 @@ function BlogCard({ blog, index }: { blog: any; index: number }) {
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50"> <div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
<Calendar size={12} className="text-primary/60" /> <Calendar size={12} className="text-primary/60" />
{blog.publishedAt ? format(new Date(blog.publishedAt), "MMM dd, yyyy") : "Draft"} {blog.publishedAt
? format(new Date(blog.publishedAt), "MMM dd, yyyy")
: "Draft"}
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50"> <div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
<Clock size={12} className="text-primary/60" /> <Clock size={12} className="text-primary/60" />5 min read
5 min read
</div> </div>
</div> </div>
@@ -95,13 +111,17 @@ function BlogCard({ blog, index }: { blog: any; index: number }) {
</h2> </h2>
<p className="text-foreground-muted text-sm leading-relaxed line-clamp-3 mb-8"> <p className="text-foreground-muted text-sm leading-relaxed line-clamp-3 mb-8">
{blog.excerpt || "No excerpt available for this post. Click to read the full article."} {blog.excerpt ||
"No excerpt available for this post. Click to read the full article."}
</p> </p>
<div className="mt-auto pt-6 border-t border-border/20 flex items-center justify-between"> <div className="mt-auto pt-6 border-t border-border/20 flex items-center justify-between">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{blog.tags.slice(0, 2).map((tag: any) => ( {blog.tags.slice(0, 2).map((tag: any) => (
<span key={tag.id} className="text-[10px] font-bold uppercase tracking-widest text-foreground-muted/60 flex items-center gap-1"> <span
key={tag.id}
className="text-[10px] font-bold uppercase tracking-widest text-foreground-muted/60 flex items-center gap-1"
>
<span className="w-1 h-1 rounded-full bg-primary" /> <span className="w-1 h-1 rounded-full bg-primary" />
{tag.name} {tag.name}
</span> </span>

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
subject: z.string().min(4, "Subject must be at least 4 characters"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export async function GET() {
try {
const siteConfig = await prisma.siteConfig.findFirst();
return NextResponse.json({ success: true, data: siteConfig });
} catch (error) {
console.error("Site config error:", error);
return NextResponse.json(
{ success: false, error: "Failed to get site config" },
{ status: 500 },
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.errors[0].message },
{ status: 400 },
);
}
const { name, email, subject, message } = parsed.data;
await prisma.contactMessage.create({
data: { name, email, subject, message },
});
return NextResponse.json({
success: true,
message: "Message received! I will get back to you soon.",
});
} catch (error) {
console.error("Contact form error:", error);
return NextResponse.json(
{ success: false, error: "Failed to send message. Please try again." },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,12 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
status: "ok",
timestamp: new Date().toISOString(),
},
{ status: 200 },
);
}

View File

@@ -1,58 +1,60 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from "framer-motion";
import { useForm } from 'react-hook-form' import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod' import { z } from "zod";
import { Send, CheckCircle2, Mail, MapPin, Clock } from 'lucide-react' import { Send, CheckCircle2, Mail, MapPin, Clock } from "lucide-react";
import type { ContactFormData } from '@/types' import type { ContactFormData } from "@/types";
const schema = z.object({ const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'), name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email('Please enter a valid email'), email: z.string().email("Please enter a valid email"),
subject: z.string().min(4, 'Subject must be at least 4 characters'), subject: z.string().min(4, "Subject must be at least 4 characters"),
message: z.string().min(10, 'Message must be at least 10 characters'), message: z.string().min(10, "Message must be at least 10 characters"),
}) });
const contactInfo = [ const contactInfo = [
{ icon: Mail, label: 'Email', value: 'hello@yourportfolio.dev' }, { icon: Mail, label: "Email", value: "developer@dzulfikri.com" },
{ icon: MapPin, label: 'Location', value: 'Indonesia' }, { icon: MapPin, label: "Location", value: "Indonesia" },
{ icon: Clock, label: 'Response time', value: 'Within 24 hours' }, { icon: Clock, label: "Response time", value: "Within 24 hours" },
] ];
export function ContactSection() { export function ContactSection() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [status, setStatus] = useState<
const [errorMsg, setErrorMsg] = useState('') "idle" | "loading" | "success" | "error"
>("idle");
const [errorMsg, setErrorMsg] = useState("");
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors }, formState: { errors },
} = useForm<ContactFormData>({ resolver: zodResolver(schema) }) } = useForm<ContactFormData>({ resolver: zodResolver(schema) });
const onSubmit = async (data: ContactFormData) => { const onSubmit = async (data: ContactFormData) => {
setStatus('loading') setStatus("loading");
try { try {
const res = await fetch('/api/contact', { const res = await fetch("/api/contact", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}) });
const json = await res.json() const json = await res.json();
if (json.success) { if (json.success) {
setStatus('success') setStatus("success");
reset() reset();
} else { } else {
setErrorMsg(json.error || 'Something went wrong') setErrorMsg(json.error || "Something went wrong");
setStatus('error') setStatus("error");
} }
} catch { } catch {
setErrorMsg('Network error. Please try again.') setErrorMsg("Network error. Please try again.");
setStatus('error') setStatus("error");
}
} }
};
return ( return (
<section id="contact" className="py-32 relative overflow-hidden"> <section id="contact" className="py-32 relative overflow-hidden">
@@ -88,8 +90,8 @@ export function ContactSection() {
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
className="text-foreground-muted mt-4 max-w-xl mx-auto" className="text-foreground-muted mt-4 max-w-xl mx-auto"
> >
Have a project in mind? Let's talk about how I can help you build something Have a project in mind? Let's talk about how I can help you build
remarkable. something remarkable.
</motion.p> </motion.p>
</div> </div>
@@ -103,7 +105,7 @@ export function ContactSection() {
className="lg:col-span-2 space-y-4" className="lg:col-span-2 space-y-4"
> >
{contactInfo.map((item) => { {contactInfo.map((item) => {
const Icon = item.icon const Icon = item.icon;
return ( return (
<div <div
key={item.label} key={item.label}
@@ -113,19 +115,28 @@ export function ContactSection() {
<Icon size={16} className="text-accent" /> <Icon size={16} className="text-accent" />
</div> </div>
<div> <div>
<p className="text-xs text-foreground-muted font-mono">{item.label}</p> <p className="text-xs text-foreground-muted font-mono">
<p className="text-sm text-foreground font-medium">{item.value}</p> {item.label}
</p>
<p className="text-sm text-foreground font-medium">
{item.value}
</p>
</div> </div>
</div> </div>
) );
})} })}
<div className="p-5 rounded-xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border mt-6"> <div className="p-5 rounded-xl bg-gradient-to-br from-accent/10 to-purple-500/5 border border-border mt-6">
<p className="text-sm text-foreground-muted leading-relaxed"> <p className="text-sm text-foreground-muted leading-relaxed">
I'm currently open to{' '} I'm currently open to{" "}
<span className="text-foreground font-medium">freelance projects</span>,{' '} <span className="text-foreground font-medium">
<span className="text-foreground font-medium">full-time positions</span>, and freelance projects
interesting technical collaborations. </span>
,{" "}
<span className="text-foreground font-medium">
full-time positions
</span>
, and interesting technical collaborations.
</p> </p>
</div> </div>
</motion.div> </motion.div>
@@ -140,7 +151,7 @@ export function ContactSection() {
> >
<div className="rounded-2xl bg-surface-elevated border border-border p-8"> <div className="rounded-2xl bg-surface-elevated border border-border p-8">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{status === 'success' ? ( {status === "success" ? (
<motion.div <motion.div
key="success" key="success"
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
@@ -155,10 +166,11 @@ export function ContactSection() {
Message Sent! Message Sent!
</h3> </h3>
<p className="text-foreground-muted text-sm"> <p className="text-foreground-muted text-sm">
Thank you for reaching out. I'll get back to you within 24 hours. Thank you for reaching out. I'll get back to you within 24
hours.
</p> </p>
<button <button
onClick={() => setStatus('idle')} onClick={() => setStatus("idle")}
className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors" className="mt-6 text-sm text-accent hover:text-accent/80 transition-colors"
> >
Send another message Send another message
@@ -179,12 +191,14 @@ export function ContactSection() {
Name Name
</label> </label>
<input <input
{...register('name')} {...register("name")}
placeholder="Your Name" placeholder="Your Name"
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm" className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/> />
{errors.name && ( {errors.name && (
<p className="mt-1.5 text-xs text-red-400">{errors.name.message}</p> <p className="mt-1.5 text-xs text-red-400">
{errors.name.message}
</p>
)} )}
</div> </div>
<div> <div>
@@ -192,13 +206,15 @@ export function ContactSection() {
Email Email
</label> </label>
<input <input
{...register('email')} {...register("email")}
type="email" type="email"
placeholder="you@email.com" placeholder="you@email.com"
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm" className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/> />
{errors.email && ( {errors.email && (
<p className="mt-1.5 text-xs text-red-400">{errors.email.message}</p> <p className="mt-1.5 text-xs text-red-400">
{errors.email.message}
</p>
)} )}
</div> </div>
</div> </div>
@@ -208,12 +224,14 @@ export function ContactSection() {
Subject Subject
</label> </label>
<input <input
{...register('subject')} {...register("subject")}
placeholder="Project inquiry / Collaboration / etc." placeholder="Project inquiry / Collaboration / etc."
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm" className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm"
/> />
{errors.subject && ( {errors.subject && (
<p className="mt-1.5 text-xs text-red-400">{errors.subject.message}</p> <p className="mt-1.5 text-xs text-red-400">
{errors.subject.message}
</p>
)} )}
</div> </div>
@@ -222,17 +240,19 @@ export function ContactSection() {
Message Message
</label> </label>
<textarea <textarea
{...register('message')} {...register("message")}
rows={5} rows={5}
placeholder="Tell me about your project, goals, and timeline..." placeholder="Tell me about your project, goals, and timeline..."
className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm resize-none" className="w-full px-4 py-3 bg-surface border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all text-sm resize-none"
/> />
{errors.message && ( {errors.message && (
<p className="mt-1.5 text-xs text-red-400">{errors.message.message}</p> <p className="mt-1.5 text-xs text-red-400">
{errors.message.message}
</p>
)} )}
</div> </div>
{status === 'error' && ( {status === "error" && (
<p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl"> <p className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 px-4 py-3 rounded-xl">
{errorMsg} {errorMsg}
</p> </p>
@@ -240,10 +260,10 @@ export function ContactSection() {
<button <button
type="submit" type="submit"
disabled={status === 'loading'} disabled={status === "loading"}
className="w-full flex items-center justify-center gap-2 px-6 py-3.5 bg-accent text-background font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow disabled:opacity-60 disabled:cursor-not-allowed" className="w-full flex items-center justify-center gap-2 px-6 py-3.5 bg-accent text-background font-semibold rounded-xl hover:bg-accent/90 transition-all hover:shadow-glow disabled:opacity-60 disabled:cursor-not-allowed"
> >
{status === 'loading' ? ( {status === "loading" ? (
<> <>
<div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin" />
Sending... Sending...
@@ -263,5 +283,5 @@ export function ContactSection() {
</div> </div>
</div> </div>
</section> </section>
) );
} }

View File

@@ -1,7 +1,15 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ArrowRight, Github, Linkedin, Mail, ChevronDown, MessageCircle, Zap } from "lucide-react"; import {
ArrowRight,
Github,
Linkedin,
Mail,
ChevronDown,
MessageCircle,
Zap,
} from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { HeroSection as HeroData } from "@/lib/constant"; import { HeroSection as HeroData } from "@/lib/constant";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -15,7 +23,7 @@ const LottieAnimation = dynamic(
loading: () => ( loading: () => (
<div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" /> <div className="w-20 h-20 animate-pulse bg-accent/20 rounded-xl" />
), ),
} },
); );
const containerVariants = { const containerVariants = {
@@ -44,17 +52,14 @@ export function HeroSection() {
const [showScrollIndicator, setShowScrollIndicator] = useState(true); const [showScrollIndicator, setShowScrollIndicator] = useState(true);
useEffect(() => { useEffect(() => {
fetch("/lottie/loading.json") fetch("/lottie/loading.json")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setLoadingAnimation(data)) .then((data) => setLoadingAnimation(data))
.catch((err) => .catch((err) => console.error("Failed to load Lottie animation:", err));
console.error("Failed to load Lottie animation:", err)
);
// Check dark mode // Check dark mode
const checkDarkMode = () => { const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark')); setIsDark(document.documentElement.classList.contains("dark"));
}; };
checkDarkMode(); checkDarkMode();
@@ -63,7 +68,7 @@ export function HeroSection() {
const observer = new MutationObserver(checkDarkMode); const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'] attributeFilter: ["class"],
}); });
return () => observer.disconnect(); return () => observer.disconnect();
@@ -83,8 +88,8 @@ export function HeroSection() {
} }
}; };
window.addEventListener('scroll', handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
return ( return (
@@ -108,9 +113,9 @@ export function HeroSection() {
className="absolute inset-0 lg:hidden opacity-10" className="absolute inset-0 lg:hidden opacity-10"
style={{ style={{
backgroundImage: `url(${HeroData.image})`, backgroundImage: `url(${HeroData.image})`,
backgroundSize: 'cover', backgroundSize: "cover",
backgroundPosition: 'center', backgroundPosition: "center",
backgroundRepeat: 'no-repeat' backgroundRepeat: "no-repeat",
}} }}
aria-hidden="true" aria-hidden="true"
/> />
@@ -289,7 +294,6 @@ export function HeroSection() {
> >
{/* Window frame */} {/* Window frame */}
<div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden"> <div className="relative h-full w-full rounded-lg border border-border bg-surface-elevated shadow-2xl overflow-hidden">
{/* Window header */} {/* Window header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated/70"> <div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface-elevated/70">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -316,15 +320,15 @@ export function HeroSection() {
<div className="absolute -bottom-8 -left-8 rounded-xl bg-surface-elevated border border-border p-4"> <div className="absolute -bottom-8 -left-8 rounded-xl bg-surface-elevated border border-border p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Zap <Zap size={24} strokeWidth={2} aria-hidden="true" />
size={24}
strokeWidth={2}
aria-hidden="true"
/>
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-foreground">10+ Projects</p> <p className="text-lg font-semibold text-foreground">
<p className="text-sm text-foreground-muted">Completed successfully</p> 10+ Projects
</p>
<p className="text-sm text-foreground-muted">
Completed successfully
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -333,15 +337,15 @@ export function HeroSection() {
<div className="absolute -top-12 -right-8 rounded-xl bg-surface-elevated border border-border p-4"> <div className="absolute -top-12 -right-8 rounded-xl bg-surface-elevated border border-border p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Zap <Zap size={24} strokeWidth={2} aria-hidden="true" />
size={24}
strokeWidth={2}
aria-hidden="true"
/>
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-foreground">Production Systems</p> <p className="text-lg font-semibold text-foreground">
<p className="text-sm text-foreground-muted">Built & Maintained</p> Production Systems
</p>
<p className="text-sm text-foreground-muted">
Built & Maintained
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -371,7 +375,11 @@ export function HeroSection() {
</span> </span>
<motion.div <motion.div
animate={{ y: [0, 4, 0] }} animate={{ y: [0, 4, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
className="flex items-center justify-center" className="flex items-center justify-center"
> >
<ChevronDown size={16} className="text-accent" /> <ChevronDown size={16} className="text-accent" />

View File

@@ -1,10 +1,25 @@
export function getVisitorId() { export function getVisitorId() {
if (typeof window === "undefined") return null;
let visitorId = localStorage.getItem("visitor_id"); let visitorId = localStorage.getItem("visitor_id");
if (!visitorId) { if (!visitorId) {
if (crypto?.randomUUID) {
visitorId = crypto.randomUUID(); visitorId = crypto.randomUUID();
} else {
visitorId = generateUUID();
}
localStorage.setItem("visitor_id", visitorId); localStorage.setItem("visitor_id", visitorId);
} }
return visitorId; return visitorId;
} }
function generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}