Fix HTML rendering for service descriptions, allow zero price for services, improve image_url handling
This commit is contained in:
640
frontend/src/components/MediaManager.js
Normal file
640
frontend/src/components/MediaManager.js
Normal 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;
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
</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 needs—whether 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">
|
||||
|
||||
Reference in New Issue
Block a user