Fix HTML rendering for service descriptions, allow zero price for services, improve image_url handling

This commit is contained in:
2026-02-01 22:31:00 -06:00
parent d3cad0e5fa
commit 72f17c8be9
32 changed files with 6958 additions and 414 deletions

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,7 +1,8 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,640 @@
import React, { useState, useEffect, useCallback } from "react";
import axios from "axios";
import {
Upload,
Image as ImageIcon,
FileText,
Video,
File,
Trash2,
Copy,
Check,
Search,
Grid,
List,
Download,
Edit2,
X,
Loader2,
Filter,
} from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "./ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { toast } from "sonner";
import { useAuth } from "../context/AuthContext";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
const MediaManager = ({ onSelect, selectable = false }) => {
const { token } = useAuth();
const [media, setMedia] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
const [mediaType, setMediaType] = useState("all");
const [viewMode, setViewMode] = useState("grid");
const [selectedMedia, setSelectedMedia] = useState(null);
const [editDialog, setEditDialog] = useState(false);
const [deleteDialog, setDeleteDialog] = useState(false);
const [mediaToDelete, setMediaToDelete] = useState(null);
const [editForm, setEditForm] = useState({
alt_text: "",
title: "",
description: "",
});
const [copiedId, setCopiedId] = useState(null);
const [dragActive, setDragActive] = useState(false);
const fetchMedia = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
limit: "24",
});
if (search) params.append("search", search);
if (mediaType !== "all") params.append("media_type", mediaType);
const response = await axios.get(`${API}/media?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
setMedia(response.data.items);
setTotalPages(response.data.pages);
setTotal(response.data.total);
} catch (error) {
console.error("Error fetching media:", error);
toast.error("Failed to load media");
} finally {
setLoading(false);
}
}, [token, page, search, mediaType]);
useEffect(() => {
fetchMedia();
}, [fetchMedia]);
const handleUpload = async (files) => {
if (!files || files.length === 0) return;
setUploading(true);
const formData = new FormData();
if (files.length === 1) {
formData.append("file", files[0]);
try {
await axios.post(`${API}/media/upload`, formData, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
});
toast.success("File uploaded successfully");
fetchMedia();
} catch (error) {
console.error("Upload error:", error);
toast.error(error.response?.data?.detail || "Upload failed");
}
} else {
// Multiple files
for (const file of files) {
formData.append("files", file);
}
try {
const response = await axios.post(
`${API}/media/upload-multiple`,
formData,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
},
);
toast.success(`${response.data.total_uploaded} files uploaded`);
if (response.data.total_errors > 0) {
toast.warning(`${response.data.total_errors} files failed to upload`);
}
fetchMedia();
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload failed");
}
}
setUploading(false);
};
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleUpload(Array.from(e.dataTransfer.files));
}
};
const handleFileSelect = (e) => {
if (e.target.files && e.target.files.length > 0) {
handleUpload(Array.from(e.target.files));
}
};
const copyUrl = (url) => {
const fullUrl = `${process.env.REACT_APP_BACKEND_URL}${url}`;
navigator.clipboard.writeText(fullUrl);
setCopiedId(url);
toast.success("URL copied to clipboard");
setTimeout(() => setCopiedId(null), 2000);
};
const openEditDialog = (item) => {
setSelectedMedia(item);
setEditForm({
alt_text: item.alt_text || "",
title: item.title || "",
description: item.description || "",
});
setEditDialog(true);
};
const handleUpdate = async () => {
try {
const formData = new FormData();
formData.append("alt_text", editForm.alt_text);
formData.append("title", editForm.title);
formData.append("description", editForm.description);
await axios.put(`${API}/media/${selectedMedia.id}`, formData, {
headers: { Authorization: `Bearer ${token}` },
});
toast.success("Media updated");
setEditDialog(false);
fetchMedia();
} catch (error) {
toast.error("Failed to update media");
}
};
const confirmDelete = (item) => {
setMediaToDelete(item);
setDeleteDialog(true);
};
const handleDelete = async () => {
try {
await axios.delete(`${API}/media/${mediaToDelete.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
toast.success("Media deleted");
setDeleteDialog(false);
setMediaToDelete(null);
fetchMedia();
} catch (error) {
toast.error("Failed to delete media");
}
};
const getMediaIcon = (type) => {
switch (type) {
case "image":
return <ImageIcon className="h-8 w-8" />;
case "video":
return <Video className="h-8 w-8" />;
case "document":
return <FileText className="h-8 w-8" />;
default:
return <File className="h-8 w-8" />;
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const handleMediaSelect = (item) => {
if (selectable && onSelect) {
onSelect(item);
} else {
setSelectedMedia(item);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
<div>
<h2 className="text-2xl font-bold font-['Outfit']">Media Library</h2>
<p className="text-muted-foreground">
{total} files Upload and manage your images and documents
</p>
</div>
<div className="flex gap-2">
<Button
variant={viewMode === "grid" ? "default" : "outline"}
size="icon"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="icon"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* Upload Area */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragActive
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-muted-foreground">Uploading...</p>
</div>
) : (
<>
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">Drag and drop files here</p>
<p className="text-sm text-muted-foreground mb-4">
Supports: JPG, PNG, GIF, WebP, SVG, PDF, DOC, MP4 and more
</p>
<label htmlFor="file-upload">
<Button asChild>
<span>
<Upload className="h-4 w-4 mr-2" />
Choose Files
</span>
</Button>
<input
id="file-upload"
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.mp4,.webm,.mov"
className="hidden"
onChange={handleFileSelect}
/>
</label>
</>
)}
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search files..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={mediaType} onValueChange={setMediaType}>
<SelectTrigger className="w-full md:w-48">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="image">Images</SelectItem>
<SelectItem value="document">Documents</SelectItem>
<SelectItem value="video">Videos</SelectItem>
</SelectContent>
</Select>
</div>
{/* Media Grid/List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : media.length === 0 ? (
<div className="text-center py-12">
<ImageIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-lg font-medium">No media files yet</p>
<p className="text-muted-foreground">
Upload your first file to get started
</p>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{media.map((item) => (
<div
key={item.id}
className={`group relative bg-card border border-border rounded-lg overflow-hidden cursor-pointer hover:border-primary transition-colors ${
selectable ? "hover:ring-2 ring-primary" : ""
}`}
onClick={() => handleMediaSelect(item)}
>
<div className="aspect-square bg-muted flex items-center justify-center">
{item.media_type === "image" ? (
<img
src={`${process.env.REACT_APP_BACKEND_URL}${item.file_url}`}
alt={item.alt_text || item.original_filename}
className="w-full h-full object-cover"
/>
) : (
<div className="text-muted-foreground">
{getMediaIcon(item.media_type)}
</div>
)}
</div>
<div className="p-2">
<p className="text-xs truncate font-medium">
{item.original_filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(item.file_size)}
</p>
</div>
{/* Actions overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
size="icon"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
copyUrl(item.file_url);
}}
>
{copiedId === item.file_url ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
openEditDialog(item);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
confirmDelete(item);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="space-y-2">
{media.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 p-4 bg-card border border-border rounded-lg hover:border-primary cursor-pointer transition-colors"
onClick={() => handleMediaSelect(item)}
>
<div className="w-16 h-16 bg-muted rounded flex items-center justify-center flex-shrink-0">
{item.media_type === "image" ? (
<img
src={`${process.env.REACT_APP_BACKEND_URL}${item.file_url}`}
alt={item.alt_text}
className="w-full h-full object-cover rounded"
/>
) : (
getMediaIcon(item.media_type)
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.original_filename}</p>
<p className="text-sm text-muted-foreground">
{formatFileSize(item.file_size)} {item.mime_type}
{item.width &&
item.height &&
`${item.width}×${item.height}`}
</p>
</div>
<div className="flex gap-2">
<Button
size="icon"
variant="outline"
onClick={(e) => {
e.stopPropagation();
copyUrl(item.file_url);
}}
>
{copiedId === item.file_url ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={(e) => {
e.stopPropagation();
openEditDialog(item);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
confirmDelete(item);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
Next
</Button>
</div>
)}
{/* Edit Dialog */}
<Dialog open={editDialog} onOpenChange={setEditDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Media</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{selectedMedia?.media_type === "image" && (
<div className="aspect-video bg-muted rounded-lg overflow-hidden">
<img
src={`${process.env.REACT_APP_BACKEND_URL}${selectedMedia?.file_url}`}
alt={selectedMedia?.alt_text}
className="w-full h-full object-contain"
/>
</div>
)}
<div className="space-y-2">
<Label>Title</Label>
<Input
value={editForm.title}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Alt Text</Label>
<Input
value={editForm.alt_text}
onChange={(e) =>
setEditForm({ ...editForm, alt_text: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={editForm.description}
onChange={(e) =>
setEditForm({ ...editForm, description: e.target.value })
}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>URL</Label>
<div className="flex gap-2">
<Input
value={`${process.env.REACT_APP_BACKEND_URL}${selectedMedia?.file_url}`}
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => copyUrl(selectedMedia?.file_url)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialog(false)}>
Cancel
</Button>
<Button onClick={handleUpdate}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={deleteDialog} onOpenChange={setDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Media?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete "{mediaToDelete?.original_filename}".
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default MediaManager;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
@@ -95,10 +95,14 @@ const MenuBar = ({ editor }) => {
};
const RichTextEditor = ({
value,
content,
onChange,
placeholder = "Enter description...",
}) => {
// Support both 'value' and 'content' props for flexibility
const initialContent = value || content || "";
const editor = useEditor({
extensions: [
StarterKit,
@@ -106,12 +110,23 @@ const RichTextEditor = ({
placeholder,
}),
],
content,
content: initialContent,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
// Update editor content when value/content prop changes externally
useEffect(() => {
if (editor && initialContent !== undefined) {
const currentContent = editor.getHTML();
// Only update if the content is actually different (prevents cursor jump)
if (currentContent !== initialContent && initialContent !== "<p></p>") {
editor.commands.setContent(initialContent);
}
}
}, [editor, initialContent]);
return (
<div
className="border border-border rounded-md overflow-hidden resize-y min-h-[240px] max-h-[600px]"

View File

@@ -49,9 +49,10 @@ const ServiceCard = ({ service }) => {
{service.name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{service.description}
</p>
<div
className="text-sm text-muted-foreground line-clamp-2 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: service.description }}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
@@ -60,9 +61,13 @@ const ServiceCard = ({ service }) => {
<div className="flex items-center justify-between pt-3 border-t border-border">
<div>
<span className="text-xs text-muted-foreground">Starting from</span>
<span className="text-xs text-muted-foreground">
{service.price > 0 ? "Starting from" : "Price"}
</span>
<p className="text-xl font-bold font-['Outfit']">
${service.price.toFixed(2)}
{service.price > 0
? `$${service.price.toFixed(2)}`
: "Contact for quote"}
</p>
</div>
<Button

View File

@@ -168,25 +168,25 @@ const Footer = () => {
<li className="flex items-start gap-3">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
123 Tech Street, Silicon Valley, CA 94000
Belmopan City, Belize
</span>
</li>
<li className="flex items-center gap-3">
<Phone className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<a
href="tel:+1234567890"
href="tel:+5016386318"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
+1 (234) 567-890
(501) 638-6318
</a>
</li>
<li className="flex items-center gap-3">
<Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<a
href="mailto:info@prompttechsolutions.com"
href="mailto:prompttechbz@gmail.com"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
info@prompttechsolutions.com
prompttechbz@gmail.com
</a>
</li>
</ul>

View File

@@ -45,12 +45,12 @@ const Navbar = () => {
return (
<header className="sticky top-0 z-50 glass glass-border">
<nav className="max-w-7xl mx-auto px-4 md:px-8">
<nav className="max-w-7xl mx-auto px-4 md:px-8 relative">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
to="/"
className="flex items-center gap-2 group"
className="flex items-center gap-2 group flex-shrink-0"
data-testid="navbar-logo"
>
<img
@@ -63,8 +63,8 @@ const Navbar = () => {
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
{/* Desktop Navigation - Centered */}
<div className="hidden md:flex items-center gap-1 absolute left-1/2 transform -translate-x-1/2">
{navLinks.map((link) => (
<Link
key={link.path}

View File

@@ -1,63 +1,107 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Users, Target, Award, Heart, ArrowRight } from "lucide-react";
import {
Users,
Target,
Award,
Heart,
ArrowRight,
Zap,
Shield,
Star,
Lightbulb,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Badge } from "../components/ui/badge";
import axios from "axios";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
// Icon mapping for company values
const iconMap = {
Target: Target,
Users: Users,
Award: Award,
Heart: Heart,
Zap: Zap,
Shield: Shield,
Star: Star,
Lightbulb: Lightbulb,
};
const About = () => {
const [team, setTeam] = useState([]);
const [values, setValues] = useState([]);
const [content, setContent] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
window.scrollTo(0, 0);
fetchAboutData();
}, []);
const team = [
{
name: "Alex Johnson",
role: "Founder & CEO",
image:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400",
},
{
name: "Sarah Williams",
role: "Head of Operations",
image:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400",
},
{
name: "Mike Chen",
role: "Lead Technician",
image:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400",
},
{
name: "Emily Davis",
role: "Customer Success",
image:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400",
},
];
const fetchAboutData = async () => {
try {
const [teamRes, valuesRes, contentRes] = await Promise.all([
axios.get(`${API}/about/team`),
axios.get(`${API}/about/values`),
axios.get(`${API}/about/content`),
]);
const values = [
{
icon: Target,
title: "Quality First",
desc: "We never compromise on the quality of our products and services.",
},
{
icon: Users,
title: "Customer Focus",
desc: "Your satisfaction is our top priority. We listen and deliver.",
},
{
icon: Award,
title: "Excellence",
desc: "We strive for excellence in everything we do.",
},
{
icon: Heart,
title: "Integrity",
desc: "Honest, transparent, and ethical business practices.",
},
];
setTeam(teamRes.data || []);
setValues(valuesRes.data || []);
// Convert content array to object keyed by section
const contentObj = {};
(contentRes.data || []).forEach((item) => {
contentObj[item.section] = item;
});
setContent(contentObj);
} catch (error) {
console.error("Error fetching about data:", error);
// Use fallback data if API fails
setTeam([]);
setValues([
{
icon: "Target",
title: "Quality First",
description:
"We never compromise on the quality of our products and services.",
},
{
icon: "Users",
title: "Customer Focus",
description:
"Your satisfaction is our top priority. We listen and deliver.",
},
{
icon: "Award",
title: "Excellence",
description: "We strive for excellence in everything we do.",
},
{
icon: "Heart",
title: "Integrity",
description: "Honest, transparent, and ethical business practices.",
},
]);
} finally {
setLoading(false);
}
};
// Get icon component from string name
const getIcon = (iconName) => {
return iconMap[iconName] || Target;
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="min-h-screen">
@@ -70,15 +114,15 @@ const About = () => {
About PromptTech Solutions
</Badge>
<h1 className="text-4xl sm:text-5xl font-bold font-['Outfit'] leading-tight">
Your Trusted
{content.hero?.title || "Your Trusted"}
<br />
<span className="text-muted-foreground">Tech Partner</span>
<span className="text-muted-foreground">
{content.hero?.subtitle || "Tech Partner"}
</span>
</h1>
<p className="text-lg text-muted-foreground leading-relaxed">
Founded in 2020, PromptTech Solutions has grown from a small
repair shop to a comprehensive tech solutions provider. We
combine quality products with expert services to deliver the
best tech experience.
{content.hero?.content ||
"Founded in 2021, PromptTech Solutions has evolved from a small repair shop into a comprehensive tech solutions provider. We're here to guide you through any technology challenge—whether it's laptops, desktops, smartphones, or other devices. With expert service and personalized support, we deliver reliable solutions for all your tech needs."}
</p>
<div className="flex flex-wrap gap-4">
<Link to="/products" data-testid="about-shop-now">
@@ -97,9 +141,12 @@ const About = () => {
<div className="relative">
<img
src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800"
alt="Our Team"
className="rounded-2xl shadow-2xl"
src={
content.hero?.image_url ||
"/uploads/media/aa5bcc15-3b1e-4ed8-8708-1a3dceb9494d.jpg"
}
alt="Tech Repair Services"
className="rounded-2xl shadow-2xl w-full h-auto max-h-[500px] object-cover"
data-testid="about-hero-image"
/>
{/* Stats Card */}
@@ -118,12 +165,14 @@ const About = () => {
<section className="py-12 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{[
{ value: "50K+", label: "Happy Customers" },
{ value: "10K+", label: "Products Sold" },
{ value: "15K+", label: "Repairs Done" },
{ value: "98%", label: "Satisfaction Rate" },
].map((stat, idx) => (
{(
content.stats?.data?.stats || [
{ value: "1K+", label: "Happy Customers" },
{ value: "500+", label: "Products Sold" },
{ value: "1,500+", label: "Repairs Done" },
{ value: "90%", label: "Satisfaction Rate" },
]
).map((stat, idx) => (
<div
key={idx}
className="text-center"
@@ -144,28 +193,37 @@ const About = () => {
<div className="max-w-4xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Our Story
{content.story?.title || "Our Story"}
</h2>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none">
<p className="text-muted-foreground leading-relaxed mb-6">
PromptTech Solutions started with a simple vision: to make quality
tech accessible and provide expert support that customers can
trust. What began as a small phone repair shop has evolved into a
full-service tech destination.
</p>
<p className="text-muted-foreground leading-relaxed mb-6">
Our team of certified technicians brings decades of combined
experience in electronics repair, from smartphones to laptops and
everything in between. We've helped thousands of customers bring
their devices back to life.
</p>
<p className="text-muted-foreground leading-relaxed">
Today, we're proud to offer a curated selection of premium
electronics alongside our repair services. Every product we sell
meets our high standards for quality, and every repair we do is
backed by our satisfaction guarantee.
</p>
{content.story?.content ? (
<div
className="text-muted-foreground leading-relaxed"
dangerouslySetInnerHTML={{ __html: content.story.content }}
/>
) : (
<>
<p className="text-muted-foreground leading-relaxed mb-6">
PromptTech Solutions started with a simple vision: to make
quality tech accessible and provide expert support that
customers can trust. What began as a small phone repair shop
has evolved into a full-service tech destination.
</p>
<p className="text-muted-foreground leading-relaxed mb-6">
Our team of certified technicians brings decades of combined
experience in electronics repair, from smartphones to laptops
and everything in between. We've helped thousands of customers
bring their devices back to life.
</p>
<p className="text-muted-foreground leading-relaxed">
Today, we're proud to offer a curated selection of premium
electronics alongside our repair services. Every product we
sell meets our high standards for quality, and every repair we
do is backed by our satisfaction guarantee.
</p>
</>
)}
</div>
</div>
</section>
@@ -183,61 +241,115 @@ const About = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{values.map((value, idx) => {
const Icon = value.icon;
return (
<div
key={idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
data-testid={`value-${idx}`}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">{value.desc}</p>
</div>
);
})}
{values.length > 0
? values.map((value, idx) => {
const Icon = getIcon(value.icon);
return (
<div
key={value.id || idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
data-testid={`value-${idx}`}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.description}
</p>
</div>
);
})
: // Fallback values
[
{
icon: Target,
title: "Quality First",
desc: "We never compromise on the quality of our products and services.",
},
{
icon: Users,
title: "Customer Focus",
desc: "Your satisfaction is our top priority. We listen and deliver.",
},
{
icon: Award,
title: "Excellence",
desc: "We strive for excellence in everything we do.",
},
{
icon: Heart,
title: "Integrity",
desc: "Honest, transparent, and ethical business practices.",
},
].map((value, idx) => {
const Icon = value.icon;
return (
<div
key={idx}
className="p-6 rounded-2xl bg-card border border-border hover-lift text-center"
data-testid={`value-${idx}`}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-7 w-7 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2 font-['Outfit']">
{value.title}
</h3>
<p className="text-sm text-muted-foreground">
{value.desc}
</p>
</div>
);
})}
</div>
</div>
</section>
{/* Team */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Meet Our Team
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
The people behind PromptTech Solutions' success
</p>
</div>
{team.length > 0 && (
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4">
Meet Our Team
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
The people behind PromptTech Solutions' success
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{team.map((member, idx) => (
<div
key={idx}
className="group text-center"
data-testid={`team-member-${idx}`}
>
<div className="relative mb-4 overflow-hidden rounded-2xl aspect-square">
<img
src={member.image}
alt={member.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div
className={`grid grid-cols-2 ${team.length <= 3 ? "md:grid-cols-3" : "md:grid-cols-4"} gap-6`}
>
{team.map((member, idx) => (
<div
key={member.id || idx}
className="group text-center"
data-testid={`team-member-${idx}`}
>
<div className="relative mb-4 overflow-hidden rounded-2xl aspect-square">
<img
src={
member.image_url ||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400"
}
alt={member.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
</div>
<h3 className="font-semibold font-['Outfit']">
{member.name}
</h3>
<p className="text-sm text-muted-foreground">{member.role}</p>
</div>
<h3 className="font-semibold font-['Outfit']">{member.name}</h3>
<p className="text-sm text-muted-foreground">{member.role}</p>
</div>
))}
))}
</div>
</div>
</div>
</section>
</section>
)}
{/* CTA */}
<section className="py-16 md:py-24 bg-primary text-primary-foreground">

File diff suppressed because it is too large Load Diff

View File

@@ -41,19 +41,19 @@ const Contact = () => {
{
icon: MapPin,
title: "Address",
content: "123 Tech Street, Silicon Valley, CA 94000",
content: "Belmopan City, Belize",
},
{
icon: Phone,
title: "Phone",
content: "+1 (234) 567-890",
link: "tel:+1234567890",
content: "(501) 638-6318",
link: "tel:+5016386318",
},
{
icon: Mail,
title: "Email",
content: "info@prompttechsolutions.com",
link: "mailto:info@prompttechsolutions.com",
content: "prompttechbz@gmail.com",
link: "mailto:prompttechbz@gmail.com",
},
{
icon: Clock,
@@ -113,14 +113,32 @@ const Contact = () => {
);
})}
{/* Map placeholder */}
<div className="aspect-video rounded-xl overflow-hidden border border-border bg-muted">
<img
src="https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1?w=800"
alt="Location Map"
className="w-full h-full object-cover"
data-testid="contact-map"
{/* Google Map - Belmopan City, Belize */}
<div
className="aspect-video rounded-xl overflow-hidden border border-border bg-muted relative group"
data-testid="contact-map"
>
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d30389.40536428!2d-88.7772!3d17.2514!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x8f5d0e6c6c6d4e6f%3A0x9f0b8c9e9e9e9e9e!2sBelmopan%2C%20Belize!5e0!3m2!1sen!2sus!4v1706824800000!5m2!1sen!2sus"
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen=""
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="PromptTech Solutions Location - Belmopan City, Belize"
className="w-full h-full"
/>
{/* Ctrl + Scroll overlay */}
<div
className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ pointerEvents: "none" }}
>
<div className="bg-background/90 px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl</kbd>
<span>+ scroll to zoom</span>
</div>
</div>
</div>
</div>

View File

@@ -1,21 +1,29 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { ArrowLeft, Clock, Calendar, Check, Phone, Mail, User } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { Label } from '../components/ui/label';
import { Separator } from '../components/ui/separator';
import React, { useState, useEffect } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import axios from "axios";
import {
ArrowLeft,
Clock,
Calendar,
Check,
Phone,
Mail,
User,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Badge } from "../components/ui/badge";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { Label } from "../components/ui/label";
import { Separator } from "../components/ui/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../components/ui/dialog';
import { toast } from 'sonner';
} from "../components/ui/dialog";
import { toast } from "sonner";
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
@@ -27,11 +35,11 @@ const ServiceDetail = () => {
const [bookingOpen, setBookingOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
preferred_date: '',
notes: ''
name: "",
email: "",
phone: "",
preferred_date: "",
notes: "",
});
useEffect(() => {
@@ -40,7 +48,7 @@ const ServiceDetail = () => {
const response = await axios.get(`${API}/services/${id}`);
setService(response.data);
} catch (error) {
console.error('Failed to fetch service:', error);
console.error("Failed to fetch service:", error);
} finally {
setLoading(false);
}
@@ -55,13 +63,21 @@ const ServiceDetail = () => {
try {
await axios.post(`${API}/services/book`, {
service_id: service.id,
...formData
...formData,
});
toast.success('Booking submitted successfully! We will contact you soon.');
toast.success(
"Booking submitted successfully! We will contact you soon.",
);
setBookingOpen(false);
setFormData({ name: '', email: '', phone: '', preferred_date: '', notes: '' });
setFormData({
name: "",
email: "",
phone: "",
preferred_date: "",
notes: "",
});
} catch (error) {
toast.error('Failed to submit booking. Please try again.');
toast.error("Failed to submit booking. Please try again.");
} finally {
setSubmitting(false);
}
@@ -85,7 +101,9 @@ const ServiceDetail = () => {
return (
<div className="min-h-screen py-8 md:py-12">
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">Service not found</h2>
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">
Service not found
</h2>
<Link to="/services">
<Button className="rounded-full">Back to Services</Button>
</Link>
@@ -124,26 +142,33 @@ const ServiceDetail = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div>
<h1 className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4" data-testid="service-title">
<h1
className="text-3xl md:text-4xl font-bold font-['Outfit'] mb-4"
data-testid="service-title"
>
{service.name}
</h1>
<p className="text-lg text-muted-foreground leading-relaxed" data-testid="service-description">
{service.description}
</p>
<div
className="text-lg text-muted-foreground leading-relaxed prose prose-lg max-w-none"
data-testid="service-description"
dangerouslySetInnerHTML={{ __html: service.description }}
/>
</div>
<Separator />
{/* What's Included */}
<div>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">What's Included</h3>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">
What's Included
</h3>
<ul className="space-y-3">
{[
'Free diagnostic assessment',
'Quality replacement parts (if needed)',
'Professional service by certified technicians',
'30-day warranty on all repairs',
'Post-service support'
"Free diagnostic assessment",
"Quality replacement parts (if needed)",
"Professional service by certified technicians",
"30-day warranty on all repairs",
"Post-service support",
].map((item, idx) => (
<li key={idx} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
@@ -159,18 +184,37 @@ const ServiceDetail = () => {
{/* Process */}
<div>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">How It Works</h3>
<h3 className="text-xl font-semibold mb-4 font-['Outfit']">
How It Works
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ step: '1', title: 'Book', desc: 'Schedule your appointment online' },
{ step: '2', title: 'Drop Off', desc: 'Bring your device to our store' },
{ step: '3', title: 'Pick Up', desc: 'Get your device back fixed' }
{
step: "1",
title: "Book",
desc: "Schedule your appointment online",
},
{
step: "2",
title: "Drop Off",
desc: "Bring your device to our store",
},
{
step: "3",
title: "Pick Up",
desc: "Get your device back fixed",
},
].map((item, idx) => (
<div key={idx} className="text-center p-4 rounded-xl bg-muted/50">
<div
key={idx}
className="text-center p-4 rounded-xl bg-muted/50"
>
<div className="w-10 h-10 mx-auto mb-3 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-bold">
{item.step}
</div>
<h4 className="font-semibold mb-1 font-['Outfit']">{item.title}</h4>
<h4 className="font-semibold mb-1 font-['Outfit']">
{item.title}
</h4>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</div>
))}
@@ -182,9 +226,16 @@ const ServiceDetail = () => {
<div className="lg:col-span-1">
<div className="sticky top-24 border border-border rounded-2xl p-6 bg-card space-y-6">
<div>
<span className="text-sm text-muted-foreground">Starting from</span>
<p className="text-3xl font-bold font-['Outfit']" data-testid="service-price">
${service.price.toFixed(2)}
<span className="text-sm text-muted-foreground">
{service.price > 0 ? "Starting from" : "Price"}
</span>
<p
className="text-3xl font-bold font-['Outfit']"
data-testid="service-price"
>
{service.price > 0
? `$${service.price.toFixed(2)}`
: "Contact for quote"}
</p>
</div>
@@ -195,13 +246,19 @@ const ServiceDetail = () => {
<Dialog open={bookingOpen} onOpenChange={setBookingOpen}>
<DialogTrigger asChild>
<Button className="w-full rounded-full btn-press" size="lg" data-testid="book-now-button">
<Button
className="w-full rounded-full btn-press"
size="lg"
data-testid="book-now-button"
>
Book Now
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-['Outfit']">Book {service.name}</DialogTitle>
<DialogTitle className="font-['Outfit']">
Book {service.name}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
@@ -213,7 +270,9 @@ const ServiceDetail = () => {
placeholder="John Doe"
className="pl-10"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
data-testid="booking-name"
/>
@@ -230,7 +289,9 @@ const ServiceDetail = () => {
placeholder="john@example.com"
className="pl-10"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
data-testid="booking-email"
/>
@@ -247,7 +308,9 @@ const ServiceDetail = () => {
placeholder="+1 234 567 890"
className="pl-10"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, phone: e.target.value })
}
required
data-testid="booking-phone"
/>
@@ -263,7 +326,12 @@ const ServiceDetail = () => {
type="date"
className="pl-10"
value={formData.preferred_date}
onChange={(e) => setFormData({ ...formData, preferred_date: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
preferred_date: e.target.value,
})
}
required
data-testid="booking-date"
/>
@@ -276,18 +344,20 @@ const ServiceDetail = () => {
id="notes"
placeholder="Describe your issue..."
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
data-testid="booking-notes"
/>
</div>
<Button
type="submit"
className="w-full rounded-full"
<Button
type="submit"
className="w-full rounded-full"
disabled={submitting}
data-testid="submit-booking"
>
{submitting ? 'Submitting...' : 'Submit Booking'}
{submitting ? "Submitting..." : "Submit Booking"}
</Button>
</form>
</DialogContent>

View File

@@ -63,7 +63,6 @@ const Services = () => {
};
const stats = [
{ value: "10K+", label: "Devices Repaired" },
{ value: "98%", label: "Success Rate" },
{ value: "24h", label: "Avg Turnaround" },
{ value: "5 Star", label: "Customer Rating" },
@@ -85,12 +84,14 @@ const Services = () => {
<span className="text-muted-foreground">Tech Solutions</span>
</h1>
<p className="text-lg text-muted-foreground max-w-lg">
From screen repairs to data recovery, our certified technicians
provide professional solutions for all your tech needs.
From screen repairs to advanced data recovery, we provide
reliable and professional solutions for all your technology
needs. Our services cover both hardware and software, ensuring
your devices run smoothly, securely, and efficiently.
</p>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
<div className="grid grid-cols-3 gap-4 pt-4">
{stats.map((stat, idx) => (
<div
key={idx}
@@ -119,6 +120,57 @@ const Services = () => {
</div>
</section>
{/* Specializations Section */}
<section className="py-16 bg-card border-b border-border">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<h2 className="text-2xl md:text-3xl font-bold font-['Outfit'] mb-6">
We Specialize In
</h2>
<ul className="space-y-3">
{[
"Computer and laptop repairs",
"Screen replacements and hardware troubleshooting",
"Software installation and system configuration",
"Windows 10 and Windows 11 repair, recovery, and optimization",
"Operating system reinstallation and OS-level repairs",
].map((item, idx) => (
<li key={idx} className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0" />
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</div>
<div>
<h2 className="text-2xl md:text-3xl font-bold font-['Outfit'] mb-6 lg:invisible">
&nbsp;
</h2>
<ul className="space-y-3">
{[
"Microsoft Office installation, activation, and setup",
"Adobe software installation and configuration",
"QuickBooks POS 2019 installation, setup, and troubleshooting",
"Server management and system synchronization across multiple devices",
"Data backup, recovery, and system cleanup",
].map((item, idx) => (
<li key={idx} className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0" />
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</div>
</div>
<p className="text-center text-muted-foreground mt-8 max-w-3xl mx-auto">
Our certified technicians provide professional solutions for all
your tech needswhether it's a single device repair or managing
systems across your entire business.
</p>
</div>
</section>
{/* Categories */}
<section className="py-8 border-b border-border sticky top-16 bg-background/95 backdrop-blur-sm z-40">
<div className="max-w-7xl mx-auto px-4 md:px-8">