tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/app/actions/workspace/createWorkspace.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { auth } from "@/lib/auth";
4 | import { PrismaClient, Role } from "@/generated/prisma";
5 | import { APIError } from "better-auth/api";
6 | import { headers } from "next/headers";
7 |
8 | const prisma = new PrismaClient();
9 |
10 | export async function createWorkspace(formData: FormData) {
11 | try {
12 | const session = await auth.api.getSession({ headers: await headers() });
13 |
14 | if (!session || !session.user || !session.user.id) {
15 | throw new APIError("UNAUTHORIZED", {
16 | message: "User not authenticated.",
17 | });
18 | }
19 |
20 | const userId = session.user.id;
21 | const name = formData.get("name") as string;
22 |
23 | if (!name || name.trim().length === 0) {
24 | throw new APIError("BAD_REQUEST", {
25 | message: "Workspace name cannot be empty.",
26 | });
27 | }
28 |
29 | // Check if user already owns a workspace or is part of one, if applicable
30 | // For now, let's assume a user can create a workspace if they don't have one linked.
31 | const existingUser = await prisma.user.findUnique({
32 | where: { id: userId },
33 | select: { workspaceId: true, ownedWorkspace: true },
34 | });
35 |
36 | if (!existingUser) {
37 | throw new APIError("NOT_FOUND", { message: "User not found." });
38 | }
39 |
40 | // Basic logic: if user already has a workspaceId or owns a workspace, prevent creation.
41 | // This can be adjusted based on product requirements (e.g., allow multiple owned workspaces, or being part of multiple).
42 | if (existingUser.workspaceId || existingUser.ownedWorkspace) {
43 | // More specific error or redirect logic could be here
44 | throw new APIError("FORBIDDEN", {
45 | message: "User is already associated with a workspace.",
46 | });
47 | }
48 |
49 | const newWorkspace = await prisma.$transaction(async (tx) => {
50 | const workspace = await tx.workspace.create({
51 | data: {
52 | name: name.trim(),
53 | ownerId: userId,
54 | // promptUsageCount will default to 0 as per schema
55 | },
56 | });
57 |
58 | await tx.user.update({
59 | where: { id: userId },
60 | data: {
61 | workspaceId: workspace.id,
62 | roleInWorkspace: Role.OWNER,
63 | },
64 | });
65 |
66 | return workspace;
67 | });
68 |
69 | return { success: true, data: newWorkspace };
70 | } catch (error) {
71 | if (error instanceof APIError) {
72 | // The first argument to APIError constructor is the code, but it might not be exposed as error.code
73 | // Relying on error.message for now.
74 | return { success: false, error: { message: error.message } };
75 | }
76 | console.error("Error creating workspace:", error);
77 | // For generic errors, we can still provide a generic code for client-side handling if needed.
78 | return {
79 | success: false,
80 | error: {
81 | message: "Failed to create workspace due to an unexpected error.",
82 | code: "INTERNAL_SERVER_ERROR",
83 | },
84 | };
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/components/workspace/create-workspace-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { toast } from "sonner";
6 | import { IconLoader2 } from "@tabler/icons-react";
7 |
8 | import { Button } from "@/components/ui/button";
9 | import { Input } from "@/components/ui/input";
10 | import { Label } from "@/components/ui/label";
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardFooter,
16 | CardHeader,
17 | CardTitle,
18 | } from "@/components/ui/card";
19 | import { createWorkspace } from "@/app/actions/workspace/createWorkspace";
20 | import { authClient } from "@/lib/auth-client";
21 |
22 | export function CreateWorkspaceForm() {
23 | const [workspaceName, setWorkspaceName] = useState("");
24 | const [isLoading, setIsLoading] = useState(false);
25 | const router = useRouter();
26 | const { data: session, isPending: isSessionLoading } =
27 | authClient.useSession();
28 |
29 | useEffect(() => {
30 | if (session?.user?.name && !workspaceName) {
31 | setWorkspaceName(`${session.user.name}'s Workspace`);
32 | } else if (!session?.user?.name && !workspaceName && !isSessionLoading) {
33 | setWorkspaceName("My Workspace");
34 | }
35 | }, [session, isSessionLoading, workspaceName]);
36 |
37 | const handleSubmit = async (event: React.FormEvent) => {
38 | event.preventDefault();
39 | setIsLoading(true);
40 |
41 | if (!workspaceName.trim()) {
42 | toast.error("Workspace name cannot be empty.");
43 | setIsLoading(false);
44 | return;
45 | }
46 |
47 | const formData = new FormData();
48 | formData.append("name", workspaceName);
49 |
50 | try {
51 | const result = await createWorkspace(formData);
52 | if (result.success && result.data) {
53 | toast.success(`Workspace "${result.data.name}" created successfully!`);
54 | router.push("/dashboard/maker");
55 | } else if (result.error) {
56 | toast.error(result.error.message || "Failed to create workspace.");
57 | } else {
58 | toast.error("An unexpected error occurred.");
59 | }
60 | } catch (error) {
61 | console.error("Create workspace form error:", error);
62 | toast.error("An unexpected error occurred while creating the workspace.");
63 | } finally {
64 | setIsLoading(false);
65 | }
66 | };
67 |
68 | return (
69 |
70 |
71 | Create New Workspace
72 |
73 | Give your new workspace a name to get started, or use the suggestion.
74 |
75 |
76 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { betterFetch } from "@better-fetch/fetch";
2 | import type { auth as authConfigType } from "@/lib/auth";
3 | import { NextRequest, NextResponse } from "next/server";
4 |
5 | type Session = typeof authConfigType.$Infer.Session;
6 |
7 | export async function middleware(request: NextRequest) {
8 | const { pathname } = request.nextUrl;
9 | const urlClone = request.nextUrl.clone();
10 |
11 | // --- [1] Authentication for /dashboard paths ---
12 | if (pathname.startsWith("/dashboard")) {
13 | const { data: session } = await betterFetch(
14 | "/api/auth/get-session",
15 | {
16 | baseURL: request.nextUrl.origin,
17 | headers: { cookie: request.headers.get("cookie") || "" },
18 | }
19 | );
20 |
21 | if (!session) {
22 | // Redirect to /sign-in, preserving original request's host and protocol.
23 | // The /sign-in path itself is excluded from the matcher's broad rule to help avoid loops.
24 | return NextResponse.redirect(new URL("/sign-in", request.url));
25 | }
26 | // If authenticated for /dashboard, allow request to proceed.
27 | // This assumes /dashboard is a global path not subject to subdomain site rewrite.
28 | return NextResponse.next();
29 | }
30 |
31 | // --- [2] Subdomain Rewriting Logic ---
32 | // This block executes for paths not starting with /dashboard, or if /dashboard auth passed
33 | // and NextResponse.next() was called (though the above block returns, effectively making this for non-/dashboard paths).
34 |
35 | const hostname = request.headers.get("host") || "";
36 | // Adjust sitemint.tech to your actual production root domain if different.
37 | const isRootDomain =
38 | hostname === "sitemint.tech" || hostname.startsWith("localhost");
39 |
40 | if (!isRootDomain) {
41 | const parts = hostname.split(".");
42 | // Expects subdomain.domain.tld (length 3+) or subdomain.localhost (length 2 for subdomain.localhost)
43 | const minPartsForSubdomain = hostname.includes("localhost")
44 | ? 2
45 | : (hostname.match(/\./g) || []).length >= 2
46 | ? 3
47 | : 2; // Heuristic for domain.tld vs domain.co.tld
48 |
49 | if (parts.length >= minPartsForSubdomain && parts[0] !== "www") {
50 | const subdomain = parts[0];
51 |
52 | // Ensure it's a valid subdomain and not already rewritten to /sites/
53 | if (subdomain && !pathname.startsWith("/sites/")) {
54 | // Exclude common static assets, API routes, etc., from subdomain rewrite.
55 | // The main `matcher` config already filters many of these.
56 | const excludedExtensions =
57 | /\.(js|css|ico|png|jpg|jpeg|svg|txt|json|map|webmanifest)$/i;
58 | const excludedPathPrefixes = [
59 | "/api/",
60 | "/_next/",
61 | "/static/",
62 | "/assets/",
63 | ];
64 | const rootFilesToExclude = ["/robots.txt", "/sitemap.xml"]; // Add any other root-specific files
65 |
66 | const isExcludedPath =
67 | excludedExtensions.test(pathname) ||
68 | excludedPathPrefixes.some((prefix) => pathname.startsWith(prefix)) ||
69 | rootFilesToExclude.includes(pathname);
70 |
71 | if (!isExcludedPath) {
72 | urlClone.pathname = `/sites/${subdomain}${pathname}`;
73 | return NextResponse.rewrite(urlClone);
74 | }
75 | }
76 | }
77 | }
78 |
79 | // If not a dashboard path needing auth, and not a subdomain path needing rewrite, proceed.
80 | return NextResponse.next();
81 | }
82 |
83 | export const config = {
84 | matcher: [
85 | // This broad matcher allows the middleware to inspect most requests.
86 | // Exclude:
87 | // - The auth session API endpoint itself (to prevent fetch loops).
88 | // - Next.js internal static/image paths.
89 | // - Common static asset folders/files.
90 | // - The /sign-in page (to simplify auth redirect logic and prevent rewrite loops).
91 | "/((?!api/auth/get-session|_next/static|_next/image|assets|favicon.ico|sw.js|manifest.json|sign-in).*)",
92 | ],
93 | };
94 |
--------------------------------------------------------------------------------
/components/section-cards.tsx:
--------------------------------------------------------------------------------
1 | import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
2 |
3 | import { Badge } from "@/components/ui/badge"
4 | import {
5 | Card,
6 | CardAction,
7 | CardDescription,
8 | CardFooter,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card"
12 |
13 | export function SectionCards() {
14 | return (
15 |
16 |
17 |
18 | Total Revenue
19 |
20 | $1,250.00
21 |
22 |
23 |
24 |
25 | +12.5%
26 |
27 |
28 |
29 |
30 |
31 | Trending up this month
32 |
33 |
34 | Visitors for the last 6 months
35 |
36 |
37 |
38 |
39 |
40 | New Customers
41 |
42 | 1,234
43 |
44 |
45 |
46 |
47 | -20%
48 |
49 |
50 |
51 |
52 |
53 | Down 20% this period
54 |
55 |
56 | Acquisition needs attention
57 |
58 |
59 |
60 |
61 |
62 | Active Accounts
63 |
64 | 45,678
65 |
66 |
67 |
68 |
69 | +12.5%
70 |
71 |
72 |
73 |
74 |
75 | Strong user retention
76 |
77 | Engagement exceed targets
78 |
79 |
80 |
81 |
82 | Growth Rate
83 |
84 | 4.5%
85 |
86 |
87 |
88 |
89 | +4.5%
90 |
91 |
92 |
93 |
94 |
95 | Steady performance increase
96 |
97 | Meets growth projections
98 |
99 |
100 |
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/components/sites/carpenter/header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SiteConfig } from "@/types/site";
4 | import { cn } from "@/lib/utils";
5 | import Link from "next/link";
6 | import { useEffect, useState } from "react";
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | interface HeaderProps {
10 | site: SiteConfig;
11 | }
12 |
13 | export function Header({ site }: HeaderProps) {
14 | const [addBorder, setAddBorder] = useState(false);
15 |
16 | useEffect(() => {
17 | const handleScroll = () => {
18 | if (window.scrollY > 20) {
19 | setAddBorder(true);
20 | } else {
21 | setAddBorder(false);
22 | }
23 | };
24 |
25 | window.addEventListener("scroll", handleScroll);
26 | return () => window.removeEventListener("scroll", handleScroll);
27 | }, []);
28 |
29 | const handleScroll = (e: React.MouseEvent) => {
30 | e.preventDefault();
31 | const href = e.currentTarget.href;
32 | const targetId = href.replace(/.*\#/, "");
33 | const elem = document.getElementById(targetId);
34 | elem?.scrollIntoView({
35 | behavior: "smooth",
36 | });
37 | };
38 |
39 | return (
40 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/app/actions/database/siteConfigActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { SiteConfig } from "@/types/site";
4 | import { prisma } from "@/lib/prisma";
5 | import { getCurrentUserWorkspaceId } from "@/lib/user";
6 | import type { Prisma } from "@/generated/prisma";
7 |
8 | export async function saveSiteConfig(siteConfig: SiteConfig): Promise {
9 | console.log("💾 Starting to save site configuration to database...");
10 |
11 | const workspaceId = await getCurrentUserWorkspaceId();
12 | if (!workspaceId) {
13 | console.error(
14 | "❌ User is not associated with a workspace or workspaceId could not be determined."
15 | );
16 | throw new Error(
17 | "User must be associated with a workspace to save site configuration."
18 | );
19 | }
20 |
21 | try {
22 | const {
23 | subdomain,
24 | name,
25 | description,
26 | theme,
27 | contact,
28 | services,
29 | socialMedia,
30 | hero,
31 | } = siteConfig;
32 |
33 | // Build update data conditionally
34 | const updateData: Prisma.SiteUpdateInput = {
35 | name,
36 | description,
37 | workspace: { connect: { id: workspaceId } },
38 | };
39 | if (theme) {
40 | updateData.theme = { upsert: { create: theme, update: theme } };
41 | }
42 | if (contact) {
43 | updateData.contact = {
44 | upsert: {
45 | create: {
46 | ...contact,
47 | email: contact.email || "",
48 | areas: contact.areas || [],
49 | },
50 | update: {
51 | ...contact,
52 | email: contact.email || "",
53 | areas: contact.areas || [],
54 | },
55 | },
56 | };
57 | }
58 | if (services) {
59 | updateData.services = {
60 | deleteMany: {},
61 | create: services.map((service) => ({
62 | title: service.title,
63 | description: service.description,
64 | price: service.price ?? "N/A",
65 | })),
66 | };
67 | }
68 | if (socialMedia) {
69 | updateData.socialMedia = {
70 | upsert: { create: socialMedia, update: socialMedia },
71 | };
72 | }
73 | if (hero) {
74 | updateData.hero = {
75 | upsert: {
76 | create: { ...hero, highlights: hero.highlights || [] },
77 | update: { ...hero, highlights: hero.highlights || [] },
78 | },
79 | };
80 | }
81 |
82 | // Build create data conditionally
83 | const createData: Prisma.SiteCreateInput = {
84 | subdomain,
85 | name,
86 | description,
87 | workspace: { connect: { id: workspaceId } },
88 | };
89 | if (theme) {
90 | createData.theme = { create: theme };
91 | }
92 | if (contact) {
93 | createData.contact = {
94 | create: {
95 | ...contact,
96 | email: contact.email || "",
97 | areas: contact.areas || [],
98 | },
99 | };
100 | }
101 | if (services) {
102 | createData.services = {
103 | create: services.map((service) => ({
104 | title: service.title,
105 | description: service.description,
106 | price: service.price ?? "N/A",
107 | })),
108 | };
109 | }
110 | if (socialMedia) {
111 | createData.socialMedia = { create: socialMedia };
112 | }
113 | if (hero) {
114 | createData.hero = {
115 | create: { ...hero, highlights: hero.highlights || [] },
116 | };
117 | }
118 |
119 | const savedSite = await prisma.site.upsert({
120 | where: { subdomain },
121 | update: updateData,
122 | create: createData,
123 | include: {
124 | workspace: true,
125 | theme: true,
126 | contact: true,
127 | services: true,
128 | socialMedia: true,
129 | hero: true,
130 | },
131 | });
132 |
133 | console.log("✅ Successfully saved site configuration to database:", {
134 | id: savedSite.id,
135 | subdomain: savedSite.subdomain,
136 | workspaceId: savedSite.workspaceId,
137 | themeId: savedSite.themeId,
138 | contactId: savedSite.contactId,
139 | socialMediaId: savedSite.socialMediaId,
140 | heroId: savedSite.heroId,
141 | });
142 | return savedSite.id;
143 | } catch (error) {
144 | console.error("❌ Error saving site configuration to database:", error);
145 | throw new Error("Failed to save site configuration to database");
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Drawer({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function DrawerTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function DrawerPortal({
21 | ...props
22 | }: React.ComponentProps) {
23 | return
24 | }
25 |
26 | function DrawerClose({
27 | ...props
28 | }: React.ComponentProps) {
29 | return
30 | }
31 |
32 | function DrawerOverlay({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | )
46 | }
47 |
48 | function DrawerContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
56 |
68 |
69 | {children}
70 |
71 |
72 | )
73 | }
74 |
75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | )
83 | }
84 |
85 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
92 | )
93 | }
94 |
95 | function DrawerTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function DrawerDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | export {
122 | Drawer,
123 | DrawerPortal,
124 | DrawerOverlay,
125 | DrawerTrigger,
126 | DrawerClose,
127 | DrawerContent,
128 | DrawerHeader,
129 | DrawerFooter,
130 | DrawerTitle,
131 | DrawerDescription,
132 | }
133 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left"
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | )
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | )
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | }
140 |
--------------------------------------------------------------------------------
/components/sites/carpenter/about.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import Image from "next/image";
5 | import { SiteConfig } from "@/types/site";
6 | import { Button } from "@/components/ui/button";
7 | import { Card, CardContent } from "@/components/ui/card";
8 | import { Badge } from "@/components/ui/badge";
9 | import { Award, Clock, ThumbsUp, Users } from "lucide-react";
10 |
11 | interface AboutProps {
12 | site: SiteConfig;
13 | }
14 |
15 | const stats = [
16 | {
17 | icon: Clock,
18 | label: "Års Erfaring",
19 | value: "20+",
20 | },
21 | {
22 | icon: Users,
23 | label: "Fornøyde Kunder",
24 | value: "500+",
25 | },
26 | {
27 | icon: ThumbsUp,
28 | label: "Fullførte Prosjekter",
29 | value: "1000+",
30 | },
31 | {
32 | icon: Award,
33 | label: "Utmerkelser",
34 | value: "15+",
35 | },
36 | ];
37 |
38 | export function About({ site }: AboutProps) {
39 | return (
40 |
41 |
42 |
43 |
53 |
54 |
60 |
61 |
62 |
20+
63 |
År med Erfaring
64 |
65 |
66 |
67 |
77 |
78 |
79 | Om Oss
80 |
81 |
82 | Håndverk med Presisjon og Kvalitet
83 |
84 |
85 | Med over to tiår med erfaring, bringer vi uovertruffen
86 | ekspertise og dedikasjon til hvert prosjekt vi påtar oss.
87 |
88 |
89 |
90 |
91 | {stats.map((stat, index) => {
92 | const Icon = stat.icon;
93 | return (
94 |
95 |
96 |
97 | {stat.value}
98 |
99 | {stat.label}
100 |
101 |
102 |
103 | );
104 | })}
105 |
106 |
107 |
108 |
109 | Vår forpliktelse til kvalitetshåndverk og oppmerksomhet på
110 | detaljer har gitt oss et rykte som en av de mest pålitelige
111 | snekkertjenestene i regionen.
112 |
113 |
(window.location.href = "#contact")}
117 | >
118 | Ta Kontakt
119 |
124 | →
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/components/sites/carpenter/portfolio.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion, Variants } from "framer-motion";
4 | import Image from "next/image";
5 | import { SiteConfig } from "@/types/site";
6 | import { Badge } from "@/components/ui/badge";
7 |
8 | interface PortfolioProps {
9 | site: SiteConfig;
10 | }
11 |
12 | const container: Variants = {
13 | hidden: { opacity: 0 },
14 | show: {
15 | opacity: 1,
16 | transition: {
17 | staggerChildren: 0.2,
18 | },
19 | },
20 | };
21 |
22 | const item: Variants = {
23 | hidden: { opacity: 0, y: 20 },
24 | show: { opacity: 1, y: 0 },
25 | };
26 |
27 | const portfolioItems = [
28 | {
29 | title: "Moderne Kjøkkenrenovering",
30 | image: "/kitchen.jpg",
31 | category: "Kjøkken",
32 | },
33 | {
34 | title: "Gipsplater Installasjon",
35 | image: "/kitchen.jpg",
36 | category: "Gipsplater",
37 | },
38 | {
39 | title: "Tak Rehabilitering",
40 | image: "/kitchen.jpg",
41 | category: "Tak",
42 | },
43 | {
44 | title: "Parkett og Gulvlegging",
45 | image: "/kitchen.jpg",
46 | category: "Gulv",
47 | },
48 | {
49 | title: "Innvendig Maling",
50 | image: "/kitchen.jpg",
51 | category: "Maling",
52 | },
53 | {
54 | title: "Skreddersydde Skap",
55 | image: "/kitchen.jpg",
56 | category: "Snekkerarbeid",
57 | },
58 | ];
59 |
60 | export function Portfolio({ site }: PortfolioProps) {
61 | return (
62 |
63 |
64 |
65 |
70 |
78 | Våre prosjekter
79 |
80 |
81 |
87 | Våre Siste Prosjekter
88 |
89 |
96 | Ta en titt på noen av våre nylige prosjekter og se kvaliteten på
97 | vårt håndverk.
98 |
99 |
100 |
101 |
108 | {portfolioItems.map((portfolioItem, index) => (
109 |
114 |
115 |
121 |
125 |
126 |
127 | {portfolioItem.title}
128 |
129 |
133 | {portfolioItem.category}
134 |
135 |
136 |
137 |
138 | ))}
139 |
140 |
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | output = "../generated/prisma"
10 | }
11 |
12 | datasource db {
13 | provider = "postgresql"
14 | url = env("DATABASE_URL")
15 | }
16 |
17 | // Core User and Workspace Models
18 | // -----------------------------------------------------------------------------
19 |
20 | model User {
21 | id String @id @default(cuid())
22 | email String @unique
23 | name String?
24 | avatarUrl String?
25 | createdAt DateTime @default(now())
26 | updatedAt DateTime @updatedAt
27 |
28 | ownedWorkspace Workspace? @relation("OwnedWorkspace") // Workspace this user owns
29 |
30 | // Link to the single workspace this user belongs to (as owner or member)
31 | workspaceId String?
32 | workspace Workspace? @relation("WorkspaceMembers", fields: [workspaceId], references: [id])
33 | roleInWorkspace Role? // User's role in that workspace
34 | emailVerified Boolean
35 | image String?
36 | sessions Session[]
37 | accounts Account[]
38 |
39 | @@map("user")
40 | }
41 |
42 | model Workspace {
43 | id String @id @default(cuid())
44 | name String
45 | promptUsageCount Int @default(0) // Example of a workspace-specific counter
46 | createdAt DateTime @default(now())
47 | updatedAt DateTime @updatedAt
48 |
49 | ownerId String @unique // ID of the User who owns this workspace
50 | owner User @relation("OwnedWorkspace", fields: [ownerId], references: [id])
51 |
52 | users User[] @relation("WorkspaceMembers") // All users belonging to this workspace
53 | sites Site[] // Sites belonging to this workspace
54 | }
55 |
56 | enum Role {
57 | OWNER
58 | ADMIN
59 | MEMBER
60 | }
61 |
62 | // Site and Related Content Models
63 | // -----------------------------------------------------------------------------
64 |
65 | model Site {
66 | id String @id @default(cuid())
67 | subdomain String @unique
68 | name String
69 | description String
70 |
71 | createdAt DateTime @default(now())
72 | updatedAt DateTime @updatedAt
73 |
74 | githubRepoUrl String?
75 | vercelProjectUrl String?
76 |
77 | // Relation to Workspace
78 | workspaceId String?
79 | workspace Workspace? @relation(fields: [workspaceId], references: [id])
80 |
81 | // Site Content Relations
82 | themeId String? @unique
83 | theme Theme? @relation(fields: [themeId], references: [id])
84 |
85 | contactId String? @unique
86 | contact Contact? @relation(fields: [contactId], references: [id])
87 |
88 | socialMediaId String? @unique
89 | socialMedia SocialMedia? @relation(fields: [socialMediaId], references: [id])
90 |
91 | heroId String? @unique
92 | hero Hero? @relation(fields: [heroId], references: [id])
93 |
94 | services Service[]
95 | }
96 |
97 | model Theme {
98 | id String @id @default(cuid())
99 | primaryColor String
100 | secondaryColor String
101 | site Site?
102 | }
103 |
104 | model Contact {
105 | id String @id @default(cuid())
106 | address String?
107 | city String?
108 | phone String?
109 | email String?
110 | workingHours String?
111 | areas String[]
112 | site Site?
113 | }
114 |
115 | model Service {
116 | id String @id @default(cuid())
117 | title String
118 | description String
119 | price String
120 | site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
121 | siteId String
122 | }
123 |
124 | model SocialMedia {
125 | id String @id @default(cuid())
126 | facebook String?
127 | instagram String?
128 | linkedin String?
129 | site Site?
130 | }
131 |
132 | model Hero {
133 | id String @id @default(cuid())
134 | mainTitle String?
135 | subtitle String?
136 | highlights String[]
137 | ctaPrimary String?
138 | ctaSecondary String?
139 | site Site?
140 | }
141 |
142 | model Session {
143 | id String @id
144 | expiresAt DateTime
145 | token String
146 | createdAt DateTime
147 | updatedAt DateTime
148 | ipAddress String?
149 | userAgent String?
150 | userId String
151 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
152 |
153 | @@unique([token])
154 | @@map("session")
155 | }
156 |
157 | model Account {
158 | id String @id
159 | accountId String
160 | providerId String
161 | userId String
162 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
163 | accessToken String?
164 | refreshToken String?
165 | idToken String?
166 | accessTokenExpiresAt DateTime?
167 | refreshTokenExpiresAt DateTime?
168 | scope String?
169 | password String?
170 | createdAt DateTime
171 | updatedAt DateTime
172 |
173 | @@map("account")
174 | }
175 |
176 | model Verification {
177 | id String @id
178 | identifier String
179 | value String
180 | expiresAt DateTime
181 | createdAt DateTime?
182 | updatedAt DateTime?
183 |
184 | @@map("verification")
185 | }
186 |
--------------------------------------------------------------------------------
/components/nav-user.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | IconCreditCard,
5 | IconDotsVertical,
6 | IconLogout,
7 | IconNotification,
8 | IconUserCircle,
9 | } from "@tabler/icons-react";
10 | import { useRouter } from "next/navigation";
11 | import { useSession, signOut } from "@/lib/auth-client";
12 |
13 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
14 | import {
15 | DropdownMenu,
16 | DropdownMenuContent,
17 | DropdownMenuGroup,
18 | DropdownMenuItem,
19 | DropdownMenuLabel,
20 | DropdownMenuSeparator,
21 | DropdownMenuTrigger,
22 | } from "@/components/ui/dropdown-menu";
23 | import {
24 | SidebarMenu,
25 | SidebarMenuButton,
26 | SidebarMenuItem,
27 | useSidebar,
28 | } from "@/components/ui/sidebar";
29 | import { Skeleton } from "@/components/ui/skeleton";
30 |
31 | export function NavUser() {
32 | const { data: session, isPending } = useSession();
33 | const router = useRouter();
34 | const { isMobile } = useSidebar();
35 |
36 | const handleSignOut = async () => {
37 | await signOut({
38 | fetchOptions: {
39 | onSuccess: () => {
40 | router.push("/sign-in");
41 | },
42 | onError: (err) => {
43 | console.error("Sign out error:", err);
44 | },
45 | },
46 | });
47 | };
48 |
49 | if (isPending) {
50 | return (
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | if (!session?.user) {
67 | return null;
68 | }
69 |
70 | const user = session.user;
71 |
72 | return (
73 |
74 |
75 |
76 |
77 |
81 |
82 |
86 |
87 | {user.name ? user.name.charAt(0).toUpperCase() : "U"}
88 |
89 |
90 |
91 |
92 | {user.name || "User"}
93 |
94 |
95 | {user.email}
96 |
97 |
98 |
99 |
100 |
101 |
107 |
108 |
109 |
110 |
114 |
115 | {user.name ? user.name.charAt(0).toUpperCase() : "U"}
116 |
117 |
118 |
119 |
120 | {user.name || "User"}
121 |
122 |
123 | {user.email}
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | Account
133 |
134 |
135 |
136 | Billing
137 |
138 |
139 |
140 | Notifications
141 |
142 |
143 |
144 |
148 |
149 | Log out
150 |
151 |
152 |
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Sitemint: Your All-in-One Web Management Toolkit
3 |
4 |
5 |
6 |
7 |
8 |
9 | Empower your web projects with Sitemint - streamlined site management, deployment, and insights at your fingertips.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Introduction ·
21 | Installation ·
22 | Tech Stack + Features ·
23 | Credits
24 |
25 |
26 |
27 | ## Introduction
28 |
29 | Welcome to Sitemint, where we are redefining web project management. Sitemint provides a comprehensive suite of tools to help you build, deploy, and manage your web applications with ease. Whether you're scraping data, managing databases, or deploying your next big idea, Sitemint is designed to streamline your workflow.
30 |
31 | Gain powerful insights and control over your web projects, enabling you to focus on innovation and development.
32 |
33 | ## What we are using
34 |
35 | Sitemint is built with a modern, powerful stack: Next.js 15, Prisma, Tailwind CSS, and Shadcn/UI.
36 |
37 | These technologies are seamlessly integrated to accelerate development and provide a top-tier user experience.
38 |
39 | ## Directory Structure
40 |
41 | Sitemint's project structure:
42 |
43 | .
44 | ├── app # Main application (Next.js App Router)
45 | │ ├── actions # Server actions (database, scraper, deploy)
46 | │ ├── api # API routes
47 | │ ├── (routes) # Application routes
48 | │ └── ...
49 | ├── components # Shared UI components
50 | ├── config # Project configuration files
51 | ├── lib # Utility functions and libraries
52 | ├── prisma # Prisma schema and migrations
53 | ├── public # Static assets
54 | ├── LICENSE.md
55 | └── README.md
56 |
57 | ## Installation
58 |
59 | Clone & create this repo locally with the following command:
60 |
61 | ```bash
62 | git clone https://github.com/codehagen/sitemint.git
63 | cd sitemint
64 | ```
65 |
66 | 1. Install dependencies using pnpm (or your preferred package manager like bun, npm, yarn):
67 |
68 | ```bash
69 | pnpm install
70 | ```
71 |
72 | 2. Copy `.env.example` to `.env.local` (or `.env`) and update the variables.
73 |
74 | ```bash
75 | cp env.example .env.local
76 | ```
77 |
78 | 3. Input all necessary environment variables. This will likely include:
79 | - Database connection string (e.g., for a PostgreSQL database like Neon)
80 | - OpenAI API Key (if using AI features)
81 | - Any other service API keys or configurations
82 |
83 | 4. Push the Prisma schema to your database:
84 | (Ensure your database is running and accessible)
85 | ```bash
86 | npx prisma db push
87 | ```
88 |
89 | 5. Start the development server:
90 | ```bash
91 | pnpm dev
92 | ```
93 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
94 |
95 | ## Tech Stack + Features
96 |
97 | ### Core Frameworks & Libraries
98 |
99 | - [Next.js](https://nextjs.org/) – React framework for building performant server-rendered and static web applications.
100 | - [Prisma](https://www.prisma.io/) – Modern ORM for Node.js and TypeScript, simplifying database access.
101 | - [React](https://react.dev/) – A JavaScript library for building user interfaces.
102 | - [OpenAI](https://openai.com/) - Integrated for AI-powered features.
103 | - [Zod](https://zod.dev/) - TypeScript-first schema declaration and validation.
104 |
105 | ### UI & UX
106 |
107 | - [Shadcn/ui](https://ui.shadcn.com/) – Re-usable components built using Radix UI and Tailwind CSS.
108 | - [Tailwind CSS](https://tailwindcss.com/) – Utility-first CSS framework for rapid UI development.
109 | - [Framer Motion](https://framer.com/motion) – Motion library for React to animate components with ease.
110 | - [@tabler/icons-react](https://tabler-icons.io/) – Icon libraries for crisp, clear visuals.
111 | - [Recharts](https://recharts.org/) - Composable charting library.
112 | - [Sonner](https://sonner.emilkowal.ski/) - Opinionated toast component for React.
113 |
114 | ### Development & Tooling
115 |
116 | - [TypeScript](https://www.typescriptlang.org/) – Strongly typed programming language that builds on JavaScript.
117 | - [ESLint](https://eslint.org/) – Pluggable linting utility for JavaScript and JSX.
118 | - [@tanstack/react-table](https://tanstack.com/table/v8) - Headless UI for building powerful tables & datagrids.
119 |
120 | ### Platforms (Example Integrations)
121 |
122 | - [Vercel](https://vercel.com/) – Easily preview & deploy changes with Git.
123 |
124 |
125 | ## Contributing
126 |
127 | We love our contributors! Here's how you can contribute:
128 |
129 | - [Open an issue](https://github.com/codehagen/sitemint/issues) if you believe you've encountered a bug.
130 | - Make a [pull request](https://github.com/codehagen/sitemint/pulls) to add new features/make quality-of-life improvements/fix bugs.
131 |
132 |
133 |
134 |
135 |
136 | ## Repo Activity (Example)
137 |
138 | 
139 |
--------------------------------------------------------------------------------
/components/app-sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { usePathname } from "next/navigation";
5 | import {
6 | IconBrightness,
7 | IconCamera,
8 | IconDashboard,
9 | IconDatabase,
10 | IconFileAi,
11 | IconFileDescription,
12 | IconFileWord,
13 | IconFolder,
14 | IconHelp,
15 | IconInnerShadowTop,
16 | IconReport,
17 | IconSettings,
18 | } from "@tabler/icons-react";
19 | import { useTheme } from "next-themes";
20 | import { useEffect, useState } from "react";
21 |
22 | import { NavMain } from "@/components/nav-main";
23 | import { NavUser } from "@/components/nav-user";
24 | import { Switch } from "@/components/ui/switch";
25 | import { Skeleton } from "@/components/ui/skeleton";
26 | import {
27 | Sidebar,
28 | SidebarContent,
29 | SidebarFooter,
30 | SidebarHeader,
31 | SidebarMenu,
32 | SidebarMenuButton,
33 | SidebarMenuItem,
34 | } from "@/components/ui/sidebar";
35 |
36 | const data = {
37 | user: {
38 | name: "shadcn",
39 | email: "m@example.com",
40 | avatar: "/avatars/shadcn.jpg",
41 | },
42 | navMain: [
43 | {
44 | title: "Dashboard",
45 | url: "#",
46 | icon: IconDashboard,
47 | },
48 | {
49 | title: "Projects",
50 | url: "/dashboard/projects",
51 | icon: IconFolder,
52 | },
53 | // {
54 | // title: "Lifecycle",
55 | // url: "#",
56 | // icon: IconListDetails,
57 | // },
58 | // {
59 | // title: "Analytics",
60 | // url: "#",
61 | // icon: IconChartBar,
62 | // },
63 | // {
64 | // title: "Team",
65 | // url: "#",
66 | // icon: IconUsers,
67 | // },
68 | ],
69 | navClouds: [
70 | {
71 | title: "Capture",
72 | icon: IconCamera,
73 | isActive: true,
74 | url: "#",
75 | items: [
76 | {
77 | title: "Active Proposals",
78 | url: "#",
79 | },
80 | {
81 | title: "Archived",
82 | url: "#",
83 | },
84 | ],
85 | },
86 | {
87 | title: "Proposal",
88 | icon: IconFileDescription,
89 | url: "#",
90 | items: [
91 | {
92 | title: "Active Proposals",
93 | url: "#",
94 | },
95 | {
96 | title: "Archived",
97 | url: "#",
98 | },
99 | ],
100 | },
101 | {
102 | title: "Prompts",
103 | icon: IconFileAi,
104 | url: "#",
105 | items: [
106 | {
107 | title: "Active Proposals",
108 | url: "#",
109 | },
110 | {
111 | title: "Archived",
112 | url: "#",
113 | },
114 | ],
115 | },
116 | ],
117 | navSecondary: [
118 | {
119 | title: "Settings",
120 | url: "#",
121 | icon: IconSettings,
122 | },
123 | {
124 | title: "Get Help",
125 | url: "#",
126 | icon: IconHelp,
127 | },
128 | ],
129 | documents: [
130 | {
131 | name: "Data Library",
132 | url: "#",
133 | icon: IconDatabase,
134 | },
135 | {
136 | name: "Reports",
137 | url: "#",
138 | icon: IconReport,
139 | },
140 | {
141 | name: "Word Assistant",
142 | url: "#",
143 | icon: IconFileWord,
144 | },
145 | ],
146 | };
147 |
148 | export function AppSidebar({ ...props }: React.ComponentProps) {
149 | const pathname = usePathname();
150 | const { resolvedTheme, setTheme } = useTheme();
151 | const [mounted, setMounted] = useState(false);
152 |
153 | useEffect(() => {
154 | setMounted(true);
155 | }, []);
156 |
157 | return (
158 |
159 |
160 |
161 |
162 |
166 |
167 |
168 | Sitemint
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | {/* */}
177 |
178 | {data.navSecondary.map((item, index) => (
179 |
180 |
181 |
182 |
183 | {item.title}
184 |
185 |
186 |
187 | ))}
188 |
189 |
190 |
191 |
192 | Dark Mode
193 | {mounted ? (
194 |
198 | setTheme(resolvedTheme === "dark" ? "light" : "dark")
199 | }
200 | aria-label="Toggle dark mode"
201 | />
202 | ) : (
203 |
204 | )}
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 | );
215 | }
216 |
--------------------------------------------------------------------------------
/components/sites/carpenter/services.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { SiteConfig } from "@/types/site";
5 | import { Button } from "@/components/ui/button";
6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7 |
8 | interface ServicesProps {
9 | site: SiteConfig;
10 | }
11 |
12 | const features = [
13 | {
14 | number: "01",
15 | title: "Gratis befaring",
16 | description:
17 | "Vi kommer hjem til deg for en grundig vurdering av prosjektet. Vi måler, diskuterer dine ønsker og gir profesjonelle råd basert på vår lange erfaring.",
18 | icon: (
19 |
34 | ),
35 | },
36 | {
37 | number: "02",
38 | title: "Detaljert pristilbud",
39 | description:
40 | "Innen 48 timer mottar du et omfattende tilbud med fastpris. Vi bryter ned kostnadene for materialer og arbeid, slik at du får full oversikt over prosjektet.",
41 | icon: (
42 |
52 | ),
53 | },
54 | {
55 | number: "03",
56 | title: "Profesjonell utførelse",
57 | description:
58 | "Våre erfarne snekkere utfører arbeidet med høyeste kvalitet og presisjon. Vi holder deg informert gjennom hele prosessen og sikrer at resultatet overgår dine forventninger.",
59 | icon: (
60 |
69 | ),
70 | },
71 | ];
72 |
73 | function FeatureCard({
74 | number,
75 | title,
76 | description,
77 | icon,
78 | index,
79 | site,
80 | }: {
81 | number: string;
82 | title: string;
83 | description: string;
84 | icon: React.ReactNode;
85 | index: number;
86 | site: SiteConfig;
87 | }) {
88 | return (
89 |
99 |
100 |
101 |
108 | {number}
109 |
110 | {title}
111 |
112 |
113 | {description}
114 | {icon}
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | export function Services({ site }: ServicesProps) {
122 | return (
123 |
124 |
125 |
131 |
139 | Slik jobber vi
140 |
141 |
142 | Kvalitetshåndverk i tre enkle steg
143 |
144 |
145 | Fra første befaring til ferdig resultat - vi sørger for en smidig
146 | prosess og profesjonell utførelse av ditt snekkerprosjekt.
147 |
148 |
149 |
150 |
151 | {features.map((feature, index) => (
152 |
158 | ))}
159 |
160 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/app/actions/scraper/scraperActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import OpenAI from "openai";
4 | import { SiteConfig } from "@/types/site";
5 | import { z } from "zod";
6 | import { zodResponseFormat } from "openai/helpers/zod";
7 | import { prisma } from "@/lib/prisma";
8 |
9 | const openai = new OpenAI({
10 | apiKey: process.env.OPENAI_API_KEY,
11 | });
12 |
13 | async function scrapeWebsite(url: string): Promise {
14 | try {
15 | console.log("🌐 Starting website scraping for:", url);
16 | const response = await fetch(url);
17 | const html = await response.text();
18 | console.log("✅ Successfully scraped website HTML");
19 | return html;
20 | } catch (error) {
21 | console.error("❌ Error scraping website:", error);
22 | throw new Error("Failed to scrape website");
23 | }
24 | }
25 |
26 | // Define Zod schema matching SiteConfig interface
27 | const SiteConfigSchema = z.object({
28 | subdomain: z.string(),
29 | name: z.string(),
30 | description: z.string(),
31 | owner: z.object({
32 | name: z.string(),
33 | email: z.string(),
34 | phone: z.string().optional(),
35 | }),
36 | theme: z.object({
37 | primaryColor: z.string(),
38 | secondaryColor: z.string(),
39 | }),
40 | contact: z.object({
41 | address: z.string().optional(),
42 | city: z.string().optional(),
43 | phone: z.string().optional(),
44 | email: z.string().optional(),
45 | workingHours: z.string().optional(),
46 | areas: z.array(z.string()).optional(),
47 | }),
48 | services: z.array(
49 | z.object({
50 | title: z.string(),
51 | description: z.string(),
52 | price: z.string(),
53 | })
54 | ),
55 | socialMedia: z
56 | .object({
57 | facebook: z.string().optional(),
58 | instagram: z.string().optional(),
59 | linkedin: z.string().optional(),
60 | })
61 | .optional(),
62 | hero: z
63 | .object({
64 | mainTitle: z.string().optional(),
65 | subtitle: z.string().optional(),
66 | highlights: z.array(z.string()).optional(),
67 | ctaPrimary: z.string().optional(),
68 | ctaSecondary: z.string().optional(),
69 | })
70 | .optional(),
71 | });
72 |
73 | export async function scrapeAndAnalyzeWebsite(
74 | url: string,
75 | workspaceId: string
76 | ): Promise {
77 | console.log("🔄 Starting website analysis process...");
78 |
79 | // Add a check for workspaceId
80 | if (!workspaceId) {
81 | console.error(
82 | "❌ Error: workspaceId is required for scrapeAndAnalyzeWebsite."
83 | );
84 | throw new Error(
85 | "workspaceId is required to analyze website and update prompt count."
86 | );
87 | }
88 |
89 | try {
90 | // 1. Scrape the website
91 | const html = await scrapeWebsite(url);
92 | console.log("📝 HTML content length:", html.length);
93 |
94 | // Create a subdomain from the URL
95 | const urlObj = new URL(url);
96 | const hostname = urlObj.hostname;
97 | // Remove www. and .no, then replace dots and dashes with hyphens
98 | const subdomain = hostname
99 | .replace(/^www\./, "")
100 | .replace(/\.no$/, "")
101 | .replace(/\./g, "-")
102 | .replace(/[^a-zA-Z0-9-]/g, "-")
103 | .toLowerCase();
104 |
105 | console.log("🤖 Sending to OpenAI for analysis...");
106 | // 2. Use OpenAI with structured outputs to analyze the content
107 | const completion = await openai.beta.chat.completions.parse({
108 | model: "gpt-4o-2024-08-06",
109 | messages: [
110 | {
111 | role: "system",
112 | content: `You are a website analyzer that extracts information to create a SiteConfig object.
113 | Extract relevant information from the HTML and make reasonable assumptions for missing data based on the business type and location.
114 | IMPORTANT: All text content must be in Norwegian (Bokmål).
115 | Use this subdomain: "${subdomain}"
116 |
117 | Guidelines for Norwegian content:
118 | - Use professional Norwegian business language
119 | - Use Norwegian currency format (NOK/kr)
120 | - Use Norwegian date/time formats
121 | - Use Norwegian phone number format (+47)
122 | - Use Norwegian address formats
123 | - Default working hours should be "Man-Fre: 07:00-16:00" if not specified
124 | - Make assumptions that align with Norwegian business practices`,
125 | },
126 | {
127 | role: "user",
128 | content: `Analyze this HTML and extract relevant information: ${html}`,
129 | },
130 | ],
131 | response_format: zodResponseFormat(SiteConfigSchema, "site_config"),
132 | });
133 |
134 | console.log("✨ Received response from OpenAI");
135 |
136 | // Handle potential refusal
137 | if (completion.choices[0].message.refusal) {
138 | console.error(
139 | "⛔ OpenAI refused to process:",
140 | completion.choices[0].message.refusal
141 | );
142 | throw new Error(completion.choices[0].message.refusal);
143 | }
144 |
145 | // Type assertion since we know the schema matches SiteConfig
146 | const siteConfig = completion.choices[0].message.parsed as SiteConfig;
147 |
148 | // Ensure we use our generated subdomain
149 | siteConfig.subdomain = subdomain;
150 |
151 | // Increment promptUsageCount for the workspace
152 | try {
153 | await prisma.workspace.update({
154 | where: { id: workspaceId },
155 | data: { promptUsageCount: { increment: 1 } },
156 | });
157 | console.log(
158 | `✅ Incremented promptUsageCount for workspace: ${workspaceId}`
159 | );
160 | } catch (dbError) {
161 | console.error(
162 | `❌ Error updating promptUsageCount for workspace ${workspaceId}:`,
163 | dbError
164 | );
165 | // Decide if this error should prevent returning siteConfig
166 | // For now, we'll log it and continue
167 | }
168 |
169 | console.log("✅ Successfully created site configuration:", {
170 | name: siteConfig.name,
171 | subdomain: siteConfig.subdomain,
172 | servicesCount: siteConfig.services?.length || 0,
173 | });
174 |
175 | return siteConfig;
176 | } catch (error) {
177 | console.error("❌ Error analyzing website:", error);
178 | throw new Error("Failed to analyze website");
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/app/dashboard/projects/[siteId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { prisma } from "@/lib/prisma";
2 | import { notFound } from "next/navigation";
3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4 | import { Badge } from "@/components/ui/badge";
5 | import { IconAlertTriangle, IconExternalLink } from "@tabler/icons-react";
6 | import Link from "next/link";
7 | import { SiteInfoEditor } from "@/components/project-edit/SiteInfoEditor";
8 | import { OwnerInfoEditor } from "@/components/project-edit/OwnerInfoEditor";
9 | import { ContactInfoEditor } from "@/components/project-edit/ContactInfoEditor";
10 | import { SocialMediaEditor } from "@/components/project-edit/SocialMediaEditor";
11 | import { HeroContentEditor } from "@/components/project-edit/HeroContentEditor";
12 | import { SetPageTitle } from "@/components/set-page-title";
13 |
14 | interface ProjectDetailsPageProps {
15 | params: {
16 | siteId: string;
17 | };
18 | }
19 |
20 | export default async function ProjectDetailsPage({
21 | params,
22 | }: ProjectDetailsPageProps) {
23 | const { siteId } = await params;
24 | const site = await prisma.site.findUnique({
25 | where: { id: siteId },
26 | include: {
27 | workspace: {
28 | include: {
29 | owner: true,
30 | },
31 | },
32 | contact: true,
33 | socialMedia: true,
34 | hero: true,
35 | services: true,
36 | theme: true,
37 | },
38 | });
39 |
40 | if (!site) {
41 | notFound();
42 | }
43 |
44 | return (
45 |
46 | {/* Page Header */}
47 |
48 |
49 |
50 |
51 |
52 |
53 | {site.name}
54 |
55 |
56 | Project ID: {site.id}
57 |
58 |
59 | {site.vercelProjectUrl ? (
60 |
66 |
67 | View Live Site
68 |
69 | ) : (
70 |
71 |
72 | Not Deployed
73 |
74 | )}
75 |
76 |
77 |
78 |
79 | {/* Main Content Area */}
80 |
81 |
82 | {/* Sidebar Column */}
83 |
84 | {/* Owner Details Card - Replaced with Editor */}
85 |
89 |
90 | {/* Contact Details Card - Replaced with Editor */}
91 |
92 |
93 | {/* Social Media Card - Replaced with Editor */}
94 |
98 |
99 |
100 | {/* Main Content Column */}
101 |
102 | {/* Site Information Card - Replaced with Editor */}
103 |
113 |
114 | {/* Hero Section Card - Replaced with Editor */}
115 |
116 |
117 | {/* Services Card */}
118 | {site.services && site.services.length > 0 && (
119 |
120 |
121 | Services Offered
122 |
123 |
124 |
125 | {site.services.map((service) => (
126 |
130 |
131 | {service.title}
132 |
133 | {service.price && (
134 |
135 | {service.price}
136 |
137 | )}
138 |
139 | {service.description}
140 |
141 |
142 | ))}
143 |
144 |
145 |
146 | )}
147 | {!site.services ||
148 | (site.services.length === 0 && (
149 |
150 |
151 |
152 | No Services Listed
153 |
154 |
155 |
156 |
157 | This project does not have any services defined yet.
158 |
159 |
160 |
161 | ))}
162 |
163 |
164 |
165 |
166 | );
167 | }
168 |
--------------------------------------------------------------------------------
/components/sites/carpenter/hero.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { buttonVariants } from "@/components/ui/button";
4 | import { cn } from "@/lib/utils";
5 | import { motion } from "framer-motion";
6 | import { Badge } from "@/components/ui/badge";
7 | import {
8 | Ruler,
9 | Phone,
10 | Trophy,
11 | Clock,
12 | HandshakeIcon,
13 | Hammer,
14 | } from "lucide-react";
15 | import Link from "next/link";
16 | import Image from "next/image";
17 | import { SiteConfig } from "@/types/site";
18 |
19 | const ease = [0.16, 1, 0.3, 1];
20 |
21 | interface HeroProps {
22 | site: SiteConfig;
23 | }
24 |
25 | function HeroFeatures({ site }: HeroProps) {
26 | return (
27 |
28 | {[
29 | {
30 | icon: Trophy,
31 | title: "Kvalitet",
32 | description: "Håndverkstradisjon",
33 | },
34 | {
35 | icon: Clock,
36 | title: "20+ år",
37 | description: "Lang erfaring",
38 | },
39 | {
40 | icon: HandshakeIcon,
41 | title: "Garanti",
42 | description: "På alt arbeid",
43 | },
44 | {
45 | icon: Hammer,
46 | title: "Skreddersydd",
47 | description: "Tilpasset dine behov",
48 | },
49 | ].map((feature, index) => (
50 |
57 |
61 | {feature.title}
62 | {feature.description}
63 |
64 | ))}
65 |
66 | );
67 | }
68 |
69 | function HeroContent({ site }: HeroProps) {
70 | return (
71 |
72 |
77 |
85 | Din lokale snekker i {site.contact.city}
86 |
87 |
88 |
94 | Håndverk med{" "}
95 |
96 | presisjon
97 |
104 |
109 |
110 |
111 |
112 |
118 | Fra skreddersydde kjøkkenløsninger til omfattende renoveringer. Vi
119 | kombinerer tradisjonelt håndverk med moderne teknikker for å skape
120 | varige resultater.
121 |
122 |
128 |
139 |
140 | Få gratis befaring
141 |
142 |
153 |
154 | Ring oss - {site.contact.phone}
155 |
156 |
157 |
158 |
159 | );
160 | }
161 |
162 | export function Hero({ site }: HeroProps) {
163 | return (
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
178 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | );
193 | }
194 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/app/actions/deploy/deploymentActions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { prisma } from "@/lib/prisma";
4 | import { Octokit } from "octokit";
5 |
6 | export async function deploySiteToVercel(siteId: string) {
7 | console.log(`🚀 Starting deployment process for site ID: ${siteId}`);
8 |
9 | const GH_OWNER = process.env.GITHUB_OWNER!;
10 | const GH_TEMPLATE_REPO = process.env.GITHUB_TEMPLATE_REPO!;
11 |
12 | if (
13 | !GH_OWNER ||
14 | !GH_TEMPLATE_REPO ||
15 | !process.env.GITHUB_TOKEN ||
16 | !process.env.VERCEL_TOKEN
17 | ) {
18 | console.error("❌ Missing critical environment variables for deployment.");
19 | throw new Error(
20 | "Deployment environment variables are not configured correctly."
21 | );
22 | }
23 |
24 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
25 |
26 | console.log("🔍 Fetching site details from database...");
27 | const site = await prisma.site.findUnique({
28 | where: { id: siteId },
29 | include: {
30 | workspace: {
31 | include: {
32 | owner: true,
33 | },
34 | },
35 | theme: true,
36 | contact: true,
37 | services: true,
38 | socialMedia: true,
39 | hero: true,
40 | },
41 | });
42 |
43 | if (!site) {
44 | console.error(`❌ Site with ID ${siteId} not found.`);
45 | throw new Error("Site not found");
46 | }
47 | console.log(`✅ Found site: ${site.name} (${site.subdomain})`);
48 |
49 | const repoName = `site-${site.subdomain}`;
50 | const configPath = "site-config.json";
51 | let repoHtmlUrl = "";
52 |
53 | console.log(
54 | `🔄 Ensuring GitHub repository "${GH_OWNER}/${repoName}" exists...`
55 | );
56 | try {
57 | const { data: repo } = await octokit.rest.repos.get({
58 | owner: GH_OWNER,
59 | repo: repoName,
60 | });
61 | repoHtmlUrl = repo.html_url;
62 | console.log(`✅ GitHub repository already exists: ${repoHtmlUrl}`);
63 | } catch (error: unknown) {
64 | if (typeof error === "object" && error !== null && "status" in error) {
65 | const httpError = error as { status: number; [key: string]: unknown };
66 | if (httpError.status === 404) {
67 | console.log(
68 | `ℹ️ GitHub repository not found. Creating from template "${GH_TEMPLATE_REPO}"...`
69 | );
70 | const { data: createdRepo } = await octokit.request(
71 | "POST /repos/{template_owner}/{template_repo}/generate",
72 | {
73 | template_owner: GH_OWNER,
74 | template_repo: GH_TEMPLATE_REPO,
75 | owner: GH_OWNER,
76 | name: repoName,
77 | private: true,
78 | include_all_branches: false,
79 | }
80 | );
81 | repoHtmlUrl = createdRepo.html_url;
82 | console.log(
83 | `✅ Successfully created GitHub repository: ${repoHtmlUrl}`
84 | );
85 | } else {
86 | console.error(
87 | "❌ Error checking/creating GitHub repository:",
88 | httpError
89 | );
90 | throw httpError;
91 | }
92 | } else {
93 | console.error(
94 | "❌ Unexpected error type while checking/creating GitHub repository:",
95 | error
96 | );
97 | if (error instanceof Error) throw error;
98 | throw new Error(
99 | "An unknown error occurred during GitHub repository check/creation."
100 | );
101 | }
102 | }
103 |
104 | console.log(`🔄 Upserting "${configPath}" in repository "${repoName}"...`);
105 | const siteConfigContent = Buffer.from(JSON.stringify(site, null, 2)).toString(
106 | "base64"
107 | );
108 | let currentFileSha: string | undefined = undefined;
109 | try {
110 | const { data: existingFile } = await octokit.rest.repos.getContent({
111 | owner: GH_OWNER,
112 | repo: repoName,
113 | path: configPath,
114 | });
115 | if (!Array.isArray(existingFile) && existingFile.type === "file") {
116 | currentFileSha = existingFile.sha;
117 | }
118 | } catch {
119 | console.log(`ℹ️ "${configPath}" not found. It will be created.`);
120 | }
121 |
122 | await octokit.rest.repos.createOrUpdateFileContents({
123 | owner: GH_OWNER,
124 | repo: repoName,
125 | path: configPath,
126 | message: `feat: update site configuration for ${site.subdomain}`,
127 | content: siteConfigContent,
128 | sha: currentFileSha,
129 | });
130 | console.log(`✅ Successfully upserted "${configPath}".`);
131 |
132 | // Vercel related code is commented out, so Vercel SDK import and related variables are removed.
133 | /*
134 | console.log(
135 | `🔄 Ensuring Vercel project "${repoName}" exists and is linked...` // Using repoName as projectName was removed
136 | );
137 | let project: any;
138 | try {
139 | const { projects: foundProjects } = await vercel.projects.getProjects({ search: repoName, limit: "1" });
140 | if (foundProjects && foundProjects.length > 0 && foundProjects[0].name === repoName) {
141 | project = foundProjects[0];
142 | console.log(`✅ Vercel project already exists: ${project.id}`);
143 | } else {
144 | throw new Error("Project not found, proceeding to creation.");
145 | }
146 | } catch (error: any) {
147 | console.log(
148 | `ℹ️ Vercel project "${repoName}" not found or error fetching. Attempting to create... (Error: ${(error as Error).message})`
149 | );
150 | const creationResult = await vercel.projects.createProject({
151 | requestBody: {
152 | name: repoName, // Using repoName
153 | framework: "nextjs",
154 | gitRepository: {
155 | repo: `${GH_OWNER}/${repoName}`,
156 | type: "github",
157 | },
158 | }
159 | });
160 | project = creationResult;
161 | console.log(`✅ Successfully created Vercel project: ${project.id}`);
162 |
163 | console.log(
164 | `🔄 Adding environment variables to Vercel project "${project.id}"...`
165 | );
166 | await vercel.projects.createProjectEnv({
167 | idOrName: project.id,
168 | requestBody: [
169 | {
170 | key: "SITE_SUBDOMAIN",
171 | value: site.subdomain,
172 | type: "plain",
173 | target: ["production", "preview", "development"],
174 | },
175 | {
176 | key: "DATABASE_URL",
177 | value: process.env.DATABASE_URL!,
178 | type: "encrypted",
179 | target: ["production", "preview", "development"],
180 | },
181 | ],
182 | });
183 | console.log("✅ Successfully added environment variables.");
184 | }
185 |
186 | let determinedUrl = "";
187 | if (project.alias && project.alias.length > 0 && project.alias[0].domain) {
188 | determinedUrl = `https://${project.alias[0].domain}`;
189 | } else {
190 | determinedUrl = `https://${repoName}.vercel.app`;
191 | }
192 | // projectUrl = determinedUrl; // projectUrl variable removed
193 | console.log(`✅ Vercel project URL: ${determinedUrl}`); // Using determinedUrl directly
194 | */
195 | // End of Vercel section
196 |
197 | console.log("💾 Updating site record with GitHub URL...");
198 | await prisma.site.update({
199 | where: { id: site.id },
200 | data: {
201 | githubRepoUrl: repoHtmlUrl,
202 | // vercelProjectUrl: projectUrl, // projectUrl variable removed, Vercel URL persistence commented out
203 | },
204 | });
205 | console.log("✅ Successfully updated site record with GitHub URL.");
206 | console.log("🎉 GitHub interaction completed successfully!");
207 |
208 | return {
209 | success: true,
210 | message: "GitHub operations completed successfully!",
211 | data: { repoHtmlUrl },
212 | };
213 | }
214 |
--------------------------------------------------------------------------------
/components/sites/carpenter/contact.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { SiteConfig } from "@/types/site";
5 | import { Button } from "@/components/ui/button";
6 | import { Input } from "@/components/ui/input";
7 | import { Textarea } from "@/components/ui/textarea";
8 | import { Card, CardContent } from "@/components/ui/card";
9 | import { Badge } from "@/components/ui/badge";
10 | import { Mail, MapPin, Phone, Clock, Send } from "lucide-react";
11 | import { toast } from "sonner";
12 |
13 | interface ContactProps {
14 | site: SiteConfig;
15 | }
16 |
17 | function getContactInfo(site: SiteConfig) {
18 | return [
19 | {
20 | icon: Phone,
21 | label: "Ring Oss",
22 | value: site.contact?.phone || "",
23 | },
24 | {
25 | icon: Mail,
26 | label: "Send E-post",
27 | value: site.contact?.email || "",
28 | },
29 | {
30 | icon: MapPin,
31 | label: "Besøk Oss",
32 | value: `${site.contact?.address}, ${site.contact?.city}`,
33 | },
34 | {
35 | icon: Clock,
36 | label: "Åpningstider",
37 | value: site.contact?.workingHours || "",
38 | },
39 | ];
40 | }
41 |
42 | export function Contact({ site }: ContactProps) {
43 | const contactInfo = getContactInfo(site);
44 |
45 | const handleSubmit = (e: React.FormEvent) => {
46 | e.preventDefault();
47 | toast.success("Takk for din henvendelse!", {
48 | description: "Vi tar kontakt med deg innen 24 timer.",
49 | });
50 | };
51 |
52 | return (
53 |
54 |
55 |
56 |
61 |
69 | La oss snakke sammen
70 |
71 |
72 |
78 | Få et uforpliktende tilbud
79 |
80 |
87 | Vi er her for å hjelpe deg med ditt neste prosjekt. Ta kontakt for
88 | en uforpliktende samtale om dine ønsker og behov.
89 |
90 |
91 |
92 |
93 |
98 |
152 |
153 |
154 |
160 |
161 | {contactInfo.map((info, index) => {
162 | const Icon = info.icon;
163 | return (
164 |
168 |
169 |
175 |
179 |
180 |
181 |
{info.label}
182 |
183 | {info.value}
184 |
185 |
186 |
187 |
188 | );
189 | })}
190 |
191 |
192 |
193 |
201 |
202 |
203 |
204 |
205 |
206 | );
207 | }
208 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: Inter, sans-serif;
10 | --font-mono: JetBrains Mono, monospace;
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | --font-serif: Merriweather, serif;
45 | --radius: 0.5rem;
46 | --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
47 | --tracking-tight: calc(var(--tracking-normal) - 0.025em);
48 | --tracking-wide: calc(var(--tracking-normal) + 0.025em);
49 | --tracking-wider: calc(var(--tracking-normal) + 0.05em);
50 | --tracking-widest: calc(var(--tracking-normal) + 0.1em);
51 | --tracking-normal: var(--tracking-normal);
52 | --shadow-2xl: var(--shadow-2xl);
53 | --shadow-xl: var(--shadow-xl);
54 | --shadow-lg: var(--shadow-lg);
55 | --shadow-md: var(--shadow-md);
56 | --shadow: var(--shadow);
57 | --shadow-sm: var(--shadow-sm);
58 | --shadow-xs: var(--shadow-xs);
59 | --shadow-2xs: var(--shadow-2xs);
60 | --spacing: var(--spacing);
61 | --letter-spacing: var(--letter-spacing);
62 | --shadow-offset-y: var(--shadow-offset-y);
63 | --shadow-offset-x: var(--shadow-offset-x);
64 | --shadow-spread: var(--shadow-spread);
65 | --shadow-blur: var(--shadow-blur);
66 | --shadow-opacity: var(--shadow-opacity);
67 | --color-shadow-color: var(--shadow-color);
68 | --color-destructive-foreground: var(--destructive-foreground);
69 | }
70 |
71 | :root {
72 | --radius: 0.5rem;
73 | --background: oklch(0.98 0.00 247.86);
74 | --foreground: oklch(0.28 0.04 260.03);
75 | --card: oklch(1.00 0 0);
76 | --card-foreground: oklch(0.28 0.04 260.03);
77 | --popover: oklch(1.00 0 0);
78 | --popover-foreground: oklch(0.28 0.04 260.03);
79 | --primary: oklch(0.59 0.20 277.12);
80 | --primary-foreground: oklch(1.00 0 0);
81 | --secondary: oklch(0.93 0.01 264.53);
82 | --secondary-foreground: oklch(0.37 0.03 259.73);
83 | --muted: oklch(0.97 0.00 264.54);
84 | --muted-foreground: oklch(0.55 0.02 264.36);
85 | --accent: oklch(0.93 0.03 272.79);
86 | --accent-foreground: oklch(0.37 0.03 259.73);
87 | --destructive: oklch(0.64 0.21 25.33);
88 | --border: oklch(0.87 0.01 258.34);
89 | --input: oklch(0.87 0.01 258.34);
90 | --ring: oklch(0.59 0.20 277.12);
91 | --chart-1: oklch(0.59 0.20 277.12);
92 | --chart-2: oklch(0.51 0.23 276.97);
93 | --chart-3: oklch(0.46 0.21 277.02);
94 | --chart-4: oklch(0.40 0.18 277.37);
95 | --chart-5: oklch(0.36 0.14 278.70);
96 | --sidebar: oklch(0.97 0.00 264.54);
97 | --sidebar-foreground: oklch(0.28 0.04 260.03);
98 | --sidebar-primary: oklch(0.59 0.20 277.12);
99 | --sidebar-primary-foreground: oklch(1.00 0 0);
100 | --sidebar-accent: oklch(0.93 0.03 272.79);
101 | --sidebar-accent-foreground: oklch(0.37 0.03 259.73);
102 | --sidebar-border: oklch(0.87 0.01 258.34);
103 | --sidebar-ring: oklch(0.59 0.20 277.12);
104 | --destructive-foreground: oklch(1.00 0 0);
105 | --font-sans: Inter, sans-serif;
106 | --font-serif: Merriweather, serif;
107 | --font-mono: JetBrains Mono, monospace;
108 | --shadow-color: hsl(0 0% 0%);
109 | --shadow-opacity: 0.1;
110 | --shadow-blur: 8px;
111 | --shadow-spread: -1px;
112 | --shadow-offset-x: 0px;
113 | --shadow-offset-y: 4px;
114 | --letter-spacing: 0em;
115 | --spacing: 0.25rem;
116 | --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
117 | --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
118 | --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
119 | --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
120 | --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
121 | --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
122 | --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
123 | --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
124 | --tracking-normal: 0em;
125 | }
126 |
127 | .dark {
128 | --background: oklch(0.21 0.04 265.75);
129 | --foreground: oklch(0.93 0.01 255.51);
130 | --card: oklch(0.28 0.04 260.03);
131 | --card-foreground: oklch(0.93 0.01 255.51);
132 | --popover: oklch(0.28 0.04 260.03);
133 | --popover-foreground: oklch(0.93 0.01 255.51);
134 | --primary: oklch(0.68 0.16 276.93);
135 | --primary-foreground: oklch(0.21 0.04 265.75);
136 | --secondary: oklch(0.34 0.03 260.91);
137 | --secondary-foreground: oklch(0.87 0.01 258.34);
138 | --muted: oklch(0.28 0.04 260.03);
139 | --muted-foreground: oklch(0.71 0.02 261.32);
140 | --accent: oklch(0.37 0.03 259.73);
141 | --accent-foreground: oklch(0.87 0.01 258.34);
142 | --destructive: oklch(0.64 0.21 25.33);
143 | --border: oklch(0.45 0.03 256.80);
144 | --input: oklch(0.45 0.03 256.80);
145 | --ring: oklch(0.68 0.16 276.93);
146 | --chart-1: oklch(0.68 0.16 276.93);
147 | --chart-2: oklch(0.59 0.20 277.12);
148 | --chart-3: oklch(0.51 0.23 276.97);
149 | --chart-4: oklch(0.46 0.21 277.02);
150 | --chart-5: oklch(0.40 0.18 277.37);
151 | --sidebar: oklch(0.28 0.04 260.03);
152 | --sidebar-foreground: oklch(0.93 0.01 255.51);
153 | --sidebar-primary: oklch(0.68 0.16 276.93);
154 | --sidebar-primary-foreground: oklch(0.21 0.04 265.75);
155 | --sidebar-accent: oklch(0.37 0.03 259.73);
156 | --sidebar-accent-foreground: oklch(0.87 0.01 258.34);
157 | --sidebar-border: oklch(0.45 0.03 256.80);
158 | --sidebar-ring: oklch(0.68 0.16 276.93);
159 | --destructive-foreground: oklch(0.21 0.04 265.75);
160 | --radius: 0.5rem;
161 | --font-sans: Inter, sans-serif;
162 | --font-serif: Merriweather, serif;
163 | --font-mono: JetBrains Mono, monospace;
164 | --shadow-color: hsl(0 0% 0%);
165 | --shadow-opacity: 0.1;
166 | --shadow-blur: 8px;
167 | --shadow-spread: -1px;
168 | --shadow-offset-x: 0px;
169 | --shadow-offset-y: 4px;
170 | --letter-spacing: 0em;
171 | --spacing: 0.25rem;
172 | --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
173 | --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
174 | --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
175 | --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
176 | --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
177 | --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
178 | --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
179 | --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
180 | }
181 |
182 | @layer base {
183 | * {
184 | @apply border-border outline-ring/50;
185 | }
186 | body {
187 | @apply bg-background text-foreground;
188 | letter-spacing: var(--tracking-normal);
189 | }
190 | }
--------------------------------------------------------------------------------
/components/project-edit/OwnerInfoEditor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState, useEffect } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import { Input } from "@/components/ui/input";
6 | import {
7 | Card,
8 | CardContent,
9 | CardHeader,
10 | CardTitle,
11 | CardFooter,
12 | } from "@/components/ui/card";
13 | import {
14 | IconUser,
15 | IconMail,
16 | IconPhone,
17 | IconDeviceFloppy,
18 | IconX,
19 | IconPencil,
20 | } from "@tabler/icons-react";
21 | import {
22 | updateSiteDetails,
23 | type UpdateSiteDetailsPayload,
24 | } from "@/app/actions/database/siteActions";
25 | import { toast } from "sonner";
26 | import type { Owner } from "@/generated/prisma/client";
27 |
28 | interface OwnerInfoEditorProps {
29 | siteId: string;
30 | initialData: Owner | null;
31 | }
32 |
33 | export function OwnerInfoEditor({ siteId, initialData }: OwnerInfoEditorProps) {
34 | const [isEditing, setIsEditing] = useState(false);
35 | // Initialize with initialData or default structure if null
36 | const [formData, setFormData] = useState(() =>
37 | initialData
38 | ? {
39 | name: initialData.name,
40 | email: initialData.email,
41 | phone: initialData.phone || "",
42 | }
43 | : { name: "", email: "", phone: "" }
44 | );
45 | const [isLoading, setIsLoading] = useState(false);
46 |
47 | useEffect(() => {
48 | setFormData(
49 | initialData
50 | ? {
51 | name: initialData.name,
52 | email: initialData.email,
53 | phone: initialData.phone || "",
54 | }
55 | : { name: "", email: "", phone: "" }
56 | );
57 | }, [initialData]);
58 |
59 | const handleInputChange = (e: React.ChangeEvent) => {
60 | const { name, value } = e.target;
61 | setFormData((prev) => ({ ...prev, [name]: value }));
62 | };
63 |
64 | const handleSave = async () => {
65 | setIsLoading(true);
66 | const payload: UpdateSiteDetailsPayload = {
67 | owner: {
68 | name: formData.name,
69 | email: formData.email,
70 | phone: formData.phone || null, // Send null if empty
71 | },
72 | };
73 |
74 | // If all owner fields are empty, and there was no initial owner, treat as no owner to submit
75 | // Or, if user wants to clear an existing owner, we might need a separate "Remove Owner" button.
76 | // For now, if all fields are blank for a *new* owner, we don't send the owner object.
77 | // If an owner exists and fields are blanked, they will be updated to blank (or null for phone).
78 | let effectivePayload = payload;
79 | if (!initialData && !formData.name && !formData.email && !formData.phone) {
80 | effectivePayload = { owner: null }; // Signal to potentially disconnect if schema allows or just don't create
81 | } else if (
82 | initialData &&
83 | !formData.name &&
84 | !formData.email &&
85 | !formData.phone
86 | ) {
87 | // If initialData existed, and user clears all fields, we send null to remove the owner.
88 | effectivePayload = { owner: null };
89 | }
90 |
91 | const result = await updateSiteDetails(siteId, effectivePayload);
92 | setIsLoading(false);
93 |
94 | if (result.success) {
95 | toast.success(result.message || "Owner details updated!");
96 | // Update formData based on the response if it contains the full owner object
97 | if (result.site?.owner) {
98 | setFormData({
99 | name: result.site.owner.name,
100 | email: result.site.owner.email,
101 | phone: result.site.owner.phone || "",
102 | });
103 | } else if (effectivePayload.owner === null) {
104 | // Owner was removed
105 | setFormData({ name: "", email: "", phone: "" });
106 | }
107 | setIsEditing(false);
108 | } else {
109 | toast.error(result.message || "Failed to update owner details.");
110 | }
111 | };
112 |
113 | const handleCancel = () => {
114 | setFormData(
115 | initialData
116 | ? {
117 | name: initialData.name,
118 | email: initialData.email,
119 | phone: initialData.phone || "",
120 | }
121 | : { name: "", email: "", phone: "" }
122 | );
123 | setIsEditing(false);
124 | };
125 |
126 | // Determine if there's any owner data to display (either initial or in form)
127 | const hasOwnerData =
128 | initialData || formData.name || formData.email || formData.phone;
129 |
130 | return (
131 |
132 |
133 |
134 |
135 | Owner Information
136 |
137 | {/* Show edit button only if there is data or if user wants to add an owner */}
138 | {!isEditing && (
139 | setIsEditing(true)}
143 | >
144 |
145 | {initialData ? "Edit" : "Add Owner"}
146 |
147 | )}
148 |
149 |
150 | {(isEditing || hasOwnerData) && (
151 |
152 | {isEditing ? (
153 | <>
154 |
155 |
159 | Full Name
160 |
161 |
168 |
169 |
170 |
174 | Email Address
175 |
176 |
184 |
185 |
186 |
190 | Phone Number (Optional)
191 |
192 |
199 |
200 | >
201 | ) : initialData ? (
202 | <>
203 |
204 |
205 | {initialData.name}
206 |
207 |
208 |
217 | {initialData.phone && (
218 |
219 |
220 | {initialData.phone}
221 |
222 | )}
223 | >
224 | ) : (
225 |
226 | No owner information provided.
227 |
228 | )}
229 |
230 | )}
231 |
232 | {isEditing && (
233 |
234 |
235 |
236 | Cancel
237 |
238 |
239 |
240 | {isLoading ? "Saving..." : "Save Changes"}
241 |
242 |
243 | )}
244 |
245 | );
246 | }
247 |
--------------------------------------------------------------------------------