| 1 | import React, {useState, useEffect, useMemo} from 'react';
|
|---|
| 2 | import {
|
|---|
| 3 | Container, Paper, Typography, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
|---|
| 4 | Button, IconButton, Avatar, TextField, Chip, CircularProgress,
|
|---|
| 5 | Menu, MenuItem, ListItemIcon, Dialog, DialogTitle, DialogContent, DialogActions
|
|---|
| 6 | } from '@mui/material';
|
|---|
| 7 |
|
|---|
| 8 | import AddIcon from '@mui/icons-material/Add';
|
|---|
| 9 | import RemoveIcon from '@mui/icons-material/Remove';
|
|---|
| 10 | import DeleteIcon from '@mui/icons-material/Delete';
|
|---|
| 11 | import CloseIcon from "@mui/icons-material/Close";
|
|---|
| 12 | import AlbumIcon from "@mui/icons-material/Album";
|
|---|
| 13 | import CableIcon from "@mui/icons-material/Cable";
|
|---|
| 14 | import RouterIcon from "@mui/icons-material/Router";
|
|---|
| 15 | import MemoryIcon from "@mui/icons-material/Memory";
|
|---|
| 16 |
|
|---|
| 17 | import {
|
|---|
| 18 | saveBuildState,
|
|---|
| 19 | onAddComponentToBuild,
|
|---|
| 20 | onRemoveComponentFromBuild,
|
|---|
| 21 | onDeleteBuild,
|
|---|
| 22 | onGetBuildState,
|
|---|
| 23 | onGetBuildComponents
|
|---|
| 24 | } from './forge.telefunc';
|
|---|
| 25 |
|
|---|
| 26 | import ComponentDialog from '../../components/ComponentDialog';
|
|---|
| 27 | import ComponentDetailsDialog from '../../components/ComponentDetailsDialog';
|
|---|
| 28 | import {onAddNewBuild, onGetAuthState, onGetComponentDetails} from "../+Layout.telefunc";
|
|---|
| 29 | import {onEditBuild} from "../dashboard/user/userDashboard.telefunc";
|
|---|
| 30 | import {BuildSlot, INITIAL_SLOTS} from "./types/buildTypes";
|
|---|
| 31 | import {renderSpecs} from "./utils/RenderSpecs";
|
|---|
| 32 | import {
|
|---|
| 33 | getMaxRamSlots,
|
|---|
| 34 | calculateUsedRamSlots,
|
|---|
| 35 | calculateUsedStorageSlots
|
|---|
| 36 | } from "./utils/componentCalculations";
|
|---|
| 37 | import {Snackbar, Alert} from '@mui/material';
|
|---|
| 38 |
|
|---|
| 39 | export default function ForgePage() {
|
|---|
| 40 | const [slots, setSlots] = useState<BuildSlot[]>(INITIAL_SLOTS);
|
|---|
| 41 | const [buildId, setBuildId] = useState<number | null>(null);
|
|---|
| 42 | const [buildName, setBuildName] = useState("");
|
|---|
| 43 | const [description, setDescription] = useState("");
|
|---|
| 44 | const [totalPrice, setTotalPrice] = useState(0);
|
|---|
| 45 | const [isSubmitting, setIsSubmitting] = useState(false);
|
|---|
| 46 |
|
|---|
| 47 | const [browserOpen, setBrowserOpen] = useState(false);
|
|---|
| 48 | const [activeSlotId, setActiveSlotId] = useState<string | null>(null);
|
|---|
| 49 | const [detailsOpen, setDetailsOpen] = useState<any>(null);
|
|---|
| 50 | const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|---|
| 51 | const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
|
|---|
| 52 | const [tempDescription, setTempDescription] = useState("");
|
|---|
| 53 | const isSubmittedRef = React.useRef(false);
|
|---|
| 54 |
|
|---|
| 55 | const [snackbar, setSnackbar] = useState<{
|
|---|
| 56 | open: boolean;
|
|---|
| 57 | message: string;
|
|---|
| 58 | severity: 'error' | 'warning' | 'info' | 'success';
|
|---|
| 59 | }>({
|
|---|
| 60 | open: false,
|
|---|
| 61 | message: '',
|
|---|
| 62 | severity: 'info'
|
|---|
| 63 | });
|
|---|
| 64 |
|
|---|
| 65 | useEffect(() => {
|
|---|
| 66 | const price = slots.reduce((sum, slot) => {
|
|---|
| 67 | const quantity = slot.component?.quantity || 1;
|
|---|
| 68 | return sum + (Number(slot.component?.price) || 0) * quantity;
|
|---|
| 69 | }, 0);
|
|---|
| 70 | setTotalPrice(price);
|
|---|
| 71 | }, [slots]);
|
|---|
| 72 |
|
|---|
| 73 | useEffect(() => {
|
|---|
| 74 | if (buildId && buildName.trim()) {
|
|---|
| 75 | const timeoutId = setTimeout(() => {
|
|---|
| 76 | saveBuildState({buildId, name: buildName.trim(), description}).catch(() => {
|
|---|
| 77 | });
|
|---|
| 78 | }, 1000);
|
|---|
| 79 |
|
|---|
| 80 | return () => clearTimeout(timeoutId);
|
|---|
| 81 | }
|
|---|
| 82 | }, [buildId, buildName, description]);
|
|---|
| 83 |
|
|---|
| 84 | useEffect(() => {
|
|---|
| 85 | if (!buildId) return;
|
|---|
| 86 |
|
|---|
| 87 | const handleBeforeUnload = () => {
|
|---|
| 88 | if (!isSubmittedRef.current) {
|
|---|
| 89 | onDeleteBuild({buildId}).catch(() => {
|
|---|
| 90 | });
|
|---|
| 91 | }
|
|---|
| 92 | };
|
|---|
| 93 |
|
|---|
| 94 | window.addEventListener('beforeunload', handleBeforeUnload);
|
|---|
| 95 |
|
|---|
| 96 | return () => {
|
|---|
| 97 | window.removeEventListener('beforeunload', handleBeforeUnload);
|
|---|
| 98 | if (!isSubmittedRef.current) {
|
|---|
| 99 | onDeleteBuild({buildId}).catch(() => {
|
|---|
| 100 | });
|
|---|
| 101 | }
|
|---|
| 102 | };
|
|---|
| 103 | }, [buildId]);
|
|---|
| 104 |
|
|---|
| 105 | useEffect(() => {
|
|---|
| 106 | const urlParams = new URLSearchParams(window.location.search);
|
|---|
| 107 | const urlBuildId = urlParams.get('buildId');
|
|---|
| 108 |
|
|---|
| 109 | if (urlBuildId && Number.isInteger(Number(urlBuildId)) && Number(urlBuildId) > 0) {
|
|---|
| 110 | const loadBuildId = Number(urlBuildId);
|
|---|
| 111 |
|
|---|
| 112 | onGetBuildState({buildId: loadBuildId})
|
|---|
| 113 | .then((buildState) => {
|
|---|
| 114 | if (buildState) {
|
|---|
| 115 | setBuildId(loadBuildId);
|
|---|
| 116 | setBuildName(buildState.build.name);
|
|---|
| 117 | setDescription(buildState.build.description || "");
|
|---|
| 118 | }
|
|---|
| 119 | })
|
|---|
| 120 | .catch(() => {
|
|---|
| 121 | })
|
|---|
| 122 | .finally(() => {
|
|---|
| 123 | onGetBuildComponents({buildId: loadBuildId})
|
|---|
| 124 | .then(async (components) => {
|
|---|
| 125 | if (components && components.length > 0) {
|
|---|
| 126 | const detailedComponents = await Promise.all(
|
|---|
| 127 | components.map(async (c: any) => {
|
|---|
| 128 | const full = await onGetComponentDetails({componentId: c.id}).catch(() => null);
|
|---|
| 129 | return full ? {
|
|---|
| 130 | ...c,
|
|---|
| 131 | ...full,
|
|---|
| 132 | details: full?.details,
|
|---|
| 133 | quantity: c.quantity || 1
|
|---|
| 134 | } : {...c, quantity: c.quantity || 1};
|
|---|
| 135 | })
|
|---|
| 136 | );
|
|---|
| 137 |
|
|---|
| 138 | const componentMap = new Map();
|
|---|
| 139 | detailedComponents.forEach((c: any) => componentMap.set(c.type, c));
|
|---|
| 140 |
|
|---|
| 141 | setSlots(prevSlots => prevSlots.map(slot => {
|
|---|
| 142 | const match = componentMap.get(slot.type);
|
|---|
| 143 | return match ? {...slot, component: match} : slot;
|
|---|
| 144 | }));
|
|---|
| 145 |
|
|---|
| 146 | window.history.replaceState({}, document.title, "/forge");
|
|---|
| 147 | }
|
|---|
| 148 | })
|
|---|
| 149 | .catch(() => {
|
|---|
| 150 | });
|
|---|
| 151 | });
|
|---|
| 152 | }
|
|---|
| 153 | }, []);
|
|---|
| 154 |
|
|---|
| 155 | const handlePickPart = (slotId: string) => {
|
|---|
| 156 | setActiveSlotId(slotId);
|
|---|
| 157 | setTimeout(() => setBrowserOpen(true), 0);
|
|---|
| 158 | };
|
|---|
| 159 |
|
|---|
| 160 | // fix: changed text, new catch and removed setActiveSlotId(null), so the component name doesn't disappear.
|
|---|
| 161 | const handleSelectComponent = async (component: any) => {
|
|---|
| 162 | if (!activeSlotId) return;
|
|---|
| 163 |
|
|---|
| 164 | try {
|
|---|
| 165 | let id = buildId;
|
|---|
| 166 | if (!id) {
|
|---|
| 167 | let result;
|
|---|
| 168 | try {
|
|---|
| 169 | result = await onAddNewBuild({
|
|---|
| 170 | name: buildName.trim() || "New Build",
|
|---|
| 171 | description: description || "Work in progress"
|
|---|
| 172 | });
|
|---|
| 173 | } catch {
|
|---|
| 174 | setSnackbar({
|
|---|
| 175 | open: true,
|
|---|
| 176 | message: 'Please log in first!',
|
|---|
| 177 | severity: 'warning'
|
|---|
| 178 | });
|
|---|
| 179 | return;
|
|---|
| 180 | }
|
|---|
| 181 |
|
|---|
| 182 | id = typeof result === 'number' ? result : (result as any)?.buildId;
|
|---|
| 183 | if (!id || !Number.isInteger(id) || id <= 0) {
|
|---|
| 184 | setSnackbar({
|
|---|
| 185 | open: true,
|
|---|
| 186 | message: 'Please log in first!',
|
|---|
| 187 | severity: 'warning'
|
|---|
| 188 | });
|
|---|
| 189 | return;
|
|---|
| 190 | }
|
|---|
| 191 | setBuildId(id);
|
|---|
| 192 | }
|
|---|
| 193 |
|
|---|
| 194 | const full = await onGetComponentDetails({componentId: component.id}).catch(() => null);
|
|---|
| 195 | const merged = full ? {...component, ...full, details: full.details, quantity: 1} : {
|
|---|
| 196 | ...component,
|
|---|
| 197 | quantity: 1
|
|---|
| 198 | };
|
|---|
| 199 |
|
|---|
| 200 | setSlots(prev => prev.map(slot =>
|
|---|
| 201 | slot.id === activeSlotId ? {...slot, component: merged} : slot
|
|---|
| 202 | ));
|
|---|
| 203 | setBrowserOpen(false);
|
|---|
| 204 |
|
|---|
| 205 | await onAddComponentToBuild({buildId: id, componentId: component.id});
|
|---|
| 206 | } catch (e) {
|
|---|
| 207 | setSnackbar({
|
|---|
| 208 | open: true,
|
|---|
| 209 | message: 'Failed to add component to build. Please try again.',
|
|---|
| 210 | severity: 'error'
|
|---|
| 211 | });
|
|---|
| 212 | } finally {
|
|---|
| 213 | // setActiveSlotId(null);
|
|---|
| 214 | }
|
|---|
| 215 | };
|
|---|
| 216 | const handleRemovePart = async (slotId: string) => {
|
|---|
| 217 | const slot = slots.find(s => s.id === slotId);
|
|---|
| 218 | if (!slot?.component || !buildId) return;
|
|---|
| 219 |
|
|---|
| 220 | const quantity = slot.component.quantity || 1;
|
|---|
| 221 |
|
|---|
| 222 | setSlots(prev => prev.map(s =>
|
|---|
| 223 | s.id === slotId ? {...s, component: null} : s
|
|---|
| 224 | ));
|
|---|
| 225 |
|
|---|
| 226 | try {
|
|---|
| 227 | for (let i = 0; i < quantity; i++) {
|
|---|
| 228 | await onRemoveComponentFromBuild({
|
|---|
| 229 | buildId,
|
|---|
| 230 | componentId: slot.component.id
|
|---|
| 231 | });
|
|---|
| 232 | }
|
|---|
| 233 | } catch (e) {
|
|---|
| 234 | console.error("Failed to remove component from server", e);
|
|---|
| 235 | }
|
|---|
| 236 | };
|
|---|
| 237 |
|
|---|
| 238 | const handleIncrementComponent = async (slotId: string) => {
|
|---|
| 239 | const slot = slots.find(s => s.id === slotId);
|
|---|
| 240 | if (!slot?.component || !buildId) return;
|
|---|
| 241 |
|
|---|
| 242 | if (slot.type === 'memory') {
|
|---|
| 243 | const maxSlots = getMaxRamSlots(slots);
|
|---|
| 244 | const currentUsed = calculateUsedRamSlots(slots);
|
|---|
| 245 | const modules = Number(slot.component.details?.modules || slot.component.modules || 1);
|
|---|
| 246 |
|
|---|
| 247 | if (maxSlots && (currentUsed + modules > maxSlots)) {
|
|---|
| 248 | setSnackbar({
|
|---|
| 249 | open: true,
|
|---|
| 250 | message: `Cannot add more RAM. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`,
|
|---|
| 251 | severity: 'error'
|
|---|
| 252 | });
|
|---|
| 253 | return;
|
|---|
| 254 | }
|
|---|
| 255 | }
|
|---|
| 256 |
|
|---|
| 257 | if (slot.type === 'storage') {
|
|---|
| 258 | const formFactor = slot.component.details?.formFactor || slot.component.formFactor;
|
|---|
| 259 | const maxSlots = 4;
|
|---|
| 260 | const currentUsed = calculateUsedStorageSlots(slots, formFactor);
|
|---|
| 261 |
|
|---|
| 262 | if (maxSlots && (currentUsed + 1 > maxSlots)) {
|
|---|
| 263 | setSnackbar({
|
|---|
| 264 | open: true,
|
|---|
| 265 | message: `Cannot add more ${formFactor} storage. Motherboard has only ${maxSlots} slots and ${currentUsed} are currently used.`,
|
|---|
| 266 | severity: 'error'
|
|---|
| 267 | });
|
|---|
| 268 | return;
|
|---|
| 269 | }
|
|---|
| 270 | }
|
|---|
| 271 |
|
|---|
| 272 | try {
|
|---|
| 273 | await onAddComponentToBuild({buildId, componentId: slot.component.id});
|
|---|
| 274 |
|
|---|
| 275 | setSlots(prev => prev.map(s =>
|
|---|
| 276 | s.id === slotId && s.component
|
|---|
| 277 | ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) + 1}}
|
|---|
| 278 | : s
|
|---|
| 279 | ));
|
|---|
| 280 | } catch (e) {
|
|---|
| 281 | setSnackbar({
|
|---|
| 282 | open: true,
|
|---|
| 283 | message: 'Failed to increment component. Please try again.',
|
|---|
| 284 | severity: 'error'
|
|---|
| 285 | });
|
|---|
| 286 | }
|
|---|
| 287 | };
|
|---|
| 288 |
|
|---|
| 289 | const handleDecrementComponent = async (slotId: string) => {
|
|---|
| 290 | const slot = slots.find(s => s.id === slotId);
|
|---|
| 291 | if (!slot?.component || !buildId) return;
|
|---|
| 292 |
|
|---|
| 293 | const currentQuantity = slot.component.quantity || 1;
|
|---|
| 294 | if (currentQuantity <= 1) return;
|
|---|
| 295 |
|
|---|
| 296 | try {
|
|---|
| 297 | await onRemoveComponentFromBuild({buildId, componentId: slot.component.id});
|
|---|
| 298 |
|
|---|
| 299 | setSlots(prev => prev.map(s =>
|
|---|
| 300 | s.id === slotId && s.component
|
|---|
| 301 | ? {...s, component: {...s.component, quantity: (s.component.quantity || 1) - 1}}
|
|---|
| 302 | : s
|
|---|
| 303 | ));
|
|---|
| 304 | } catch (e) {
|
|---|
| 305 | setSnackbar({
|
|---|
| 306 | open: true,
|
|---|
| 307 | message: 'Failed to decrement component. Please try again.',
|
|---|
| 308 | severity: 'error'
|
|---|
| 309 | });
|
|---|
| 310 | }
|
|---|
| 311 | };
|
|---|
| 312 |
|
|---|
| 313 | const handleAddSlot = (type: string, label: string) => {
|
|---|
| 314 | const count = slots.filter(s => s.type === type).length;
|
|---|
| 315 | const newSlot: BuildSlot = {
|
|---|
| 316 | id: `${type}_${count + 1}`,
|
|---|
| 317 | type,
|
|---|
| 318 | label: `${label} ${count > 0 ? count + 1 : ''}`,
|
|---|
| 319 | component: null,
|
|---|
| 320 | required: false
|
|---|
| 321 | };
|
|---|
| 322 | setSlots(prev => [...prev, newSlot]);
|
|---|
| 323 | setAnchorEl(null);
|
|---|
| 324 | };
|
|---|
| 325 |
|
|---|
| 326 | const handleDeleteSlot = (slotId: string) => {
|
|---|
| 327 | const slot = slots.find(s => s.id === slotId);
|
|---|
| 328 | if (slot?.component) {
|
|---|
| 329 | handleRemovePart(slotId).catch(() => {
|
|---|
| 330 | });
|
|---|
| 331 | }
|
|---|
| 332 | setSlots(prev => prev.filter(s => s.id !== slotId));
|
|---|
| 333 | };
|
|---|
| 334 |
|
|---|
| 335 |
|
|---|
| 336 | const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|---|
| 337 | useEffect(() => {
|
|---|
| 338 | onGetAuthState().then(userData => {
|
|---|
| 339 | setIsLoggedIn(!!userData?.userId);
|
|---|
| 340 | });
|
|---|
| 341 | }, []);
|
|---|
| 342 |
|
|---|
| 343 |
|
|---|
| 344 | const handleSubmit = () => {
|
|---|
| 345 | if (!isLoggedIn) {
|
|---|
| 346 | window.location.href = '/auth/login'
|
|---|
| 347 | return;
|
|---|
| 348 | }
|
|---|
| 349 |
|
|---|
| 350 | if (!buildName.trim()) {
|
|---|
| 351 | setSnackbar({
|
|---|
| 352 | open: true,
|
|---|
| 353 | message: 'Please give your build a name!',
|
|---|
| 354 | severity: 'warning'
|
|---|
| 355 | });
|
|---|
| 356 | return;
|
|---|
| 357 | }
|
|---|
| 358 |
|
|---|
| 359 | if (!buildId) {
|
|---|
| 360 | setSnackbar({
|
|---|
| 361 | open: true,
|
|---|
| 362 | message: 'Please add at least one component to your build before submitting!',
|
|---|
| 363 | severity: 'warning'
|
|---|
| 364 | });
|
|---|
| 365 | return;
|
|---|
| 366 | }
|
|---|
| 367 |
|
|---|
| 368 | setTempDescription(description || "");
|
|---|
| 369 | setSubmitDialogOpen(true);
|
|---|
| 370 | };
|
|---|
| 371 |
|
|---|
| 372 | const handleSubmitConfirm = async () => {
|
|---|
| 373 | if (!buildId) return;
|
|---|
| 374 |
|
|---|
| 375 | setIsSubmitting(true);
|
|---|
| 376 | setSubmitDialogOpen(false);
|
|---|
| 377 |
|
|---|
| 378 | try {
|
|---|
| 379 | await saveBuildState({buildId, name: buildName.trim(), description: tempDescription});
|
|---|
| 380 | const result = await onEditBuild({buildId});
|
|---|
| 381 | if (!result) throw new Error("Failed to save build");
|
|---|
| 382 |
|
|---|
| 383 | isSubmittedRef.current = true;
|
|---|
| 384 | window.location.href = "/dashboard/user";
|
|---|
| 385 | } catch (e) {
|
|---|
| 386 | console.error(e);
|
|---|
| 387 | setSnackbar({
|
|---|
| 388 | open: true,
|
|---|
| 389 | message: 'Failed to save build. Please try again.',
|
|---|
| 390 | severity: 'error'
|
|---|
| 391 | });
|
|---|
| 392 | return;
|
|---|
| 393 | } finally {
|
|---|
| 394 | setIsSubmitting(false);
|
|---|
| 395 | }
|
|---|
| 396 | };
|
|---|
| 397 |
|
|---|
| 398 | const activeSlotType = useMemo(() => {
|
|---|
| 399 | if (!activeSlotId) return null;
|
|---|
| 400 | return slots.find(s => s.id === activeSlotId)?.type || null;
|
|---|
| 401 | }, [slots, activeSlotId]);
|
|---|
| 402 |
|
|---|
| 403 | return (
|
|---|
| 404 | <Container maxWidth="xl" sx={{mt: 0, mb: 10}}>
|
|---|
| 405 | <Paper sx={{p: 4, mb: 0, bgcolor: '#ff8201', border: '1px solid #1e1e1e', color: 'white'}}>
|
|---|
| 406 | <Typography variant="h4" align="center" fontWeight="bold">Forge Your Machine</Typography>
|
|---|
| 407 | <Box sx={{display: 'flex', justifyContent: 'center', mt: 2}}>
|
|---|
| 408 | <TextField
|
|---|
| 409 | label="Build Name *"
|
|---|
| 410 | value={buildName}
|
|---|
| 411 | onChange={e => setBuildName(e.target.value)}
|
|---|
| 412 | sx={{bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 1, color: 'white', width: '200px'}}
|
|---|
| 413 | />
|
|---|
| 414 | </Box>
|
|---|
| 415 |
|
|---|
| 416 | </Paper>
|
|---|
| 417 |
|
|---|
| 418 | <TableContainer component={Paper} elevation={3}>
|
|---|
| 419 | <Table sx={{minWidth: 650}}>
|
|---|
| 420 | <TableHead sx={{bgcolor: '#1e1e1e'}}>
|
|---|
| 421 | <TableRow>
|
|---|
| 422 | <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Component</TableCell>
|
|---|
| 423 | <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Selection & Specs</TableCell>
|
|---|
| 424 | <TableCell sx={{color: 'white', fontWeight: 'bold'}}>Price</TableCell>
|
|---|
| 425 | <TableCell sx={{color: 'white', fontWeight: 'bold'}} align="right">Actions</TableCell>
|
|---|
| 426 | </TableRow>
|
|---|
| 427 | </TableHead>
|
|---|
| 428 | <TableBody>
|
|---|
| 429 | {slots.map((slot) => (
|
|---|
| 430 | <TableRow key={slot.id} hover>
|
|---|
| 431 | <TableCell width="15%" sx={{
|
|---|
| 432 | fontWeight: 'bold',
|
|---|
| 433 | bgcolor: '#1e1e1e',
|
|---|
| 434 | color: 'white',
|
|---|
| 435 | verticalAlign: 'top',
|
|---|
| 436 | pt: 3,
|
|---|
| 437 | borderRight: '1px solid #333'
|
|---|
| 438 | }}>
|
|---|
| 439 | {slot.label}
|
|---|
| 440 | {slot.required &&
|
|---|
| 441 | <Chip label="Required" size="small" color="error" sx={{ml: 1, height: 20}}/>}
|
|---|
| 442 | </TableCell>
|
|---|
| 443 |
|
|---|
| 444 | <TableCell>
|
|---|
| 445 | {slot.component ? (
|
|---|
| 446 | <Box sx={{display: 'flex', gap: 2, alignItems: 'flex-start'}}>
|
|---|
| 447 | <Avatar
|
|---|
| 448 | variant="rounded"
|
|---|
| 449 | src={slot.component.imgUrl || slot.component.img_url}
|
|---|
| 450 | sx={{width: 60, height: 60, bgcolor: '#eee'}}
|
|---|
| 451 | >
|
|---|
| 452 | {slot.component.brand?.[0]}
|
|---|
| 453 | </Avatar>
|
|---|
| 454 | <Box>
|
|---|
| 455 | <Typography
|
|---|
| 456 | variant="subtitle1"
|
|---|
| 457 | fontWeight="bold"
|
|---|
| 458 | sx={{cursor: 'pointer', color: 'primary.main'}}
|
|---|
| 459 | onClick={() => setDetailsOpen(slot.component)}
|
|---|
| 460 | >
|
|---|
| 461 | {slot.component.name}
|
|---|
| 462 | </Typography>
|
|---|
| 463 | <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5}}>
|
|---|
| 464 | {renderSpecs(slot.component, slot.type)}
|
|---|
| 465 | </Box>
|
|---|
| 466 | </Box>
|
|---|
| 467 | </Box>
|
|---|
| 468 | ) : (
|
|---|
| 469 | <Button
|
|---|
| 470 | variant="outlined"
|
|---|
| 471 | startIcon={<AddIcon/>}
|
|---|
| 472 | onClick={() => handlePickPart(slot.id)}
|
|---|
| 473 | sx={{textTransform: 'none', color: '#666', borderColor: '#ccc'}}
|
|---|
| 474 | >
|
|---|
| 475 | Choose {slot.label}
|
|---|
| 476 | </Button>
|
|---|
| 477 | )}
|
|---|
| 478 | </TableCell>
|
|---|
| 479 |
|
|---|
| 480 | <TableCell width="10%" sx={{verticalAlign: 'top', pt: 3}}>
|
|---|
| 481 | {slot.component ? (
|
|---|
| 482 | <>
|
|---|
| 483 | ${(Number(slot.component.price) * (slot.component.quantity || 1)).toFixed(2)}
|
|---|
| 484 | {(slot.component.quantity || 1) > 1 && (
|
|---|
| 485 | <Typography variant="caption" display="block" color="text.secondary">
|
|---|
| 486 | ${Number(slot.component.price).toFixed(2)} each
|
|---|
| 487 | </Typography>
|
|---|
| 488 | )}
|
|---|
| 489 | </>
|
|---|
| 490 | ) : '-'}
|
|---|
| 491 | </TableCell>
|
|---|
| 492 |
|
|---|
| 493 | <TableCell align="right" width="15%" sx={{verticalAlign: 'top', pt: 2}}>
|
|---|
| 494 | <Box sx={{
|
|---|
| 495 | display: 'flex',
|
|---|
| 496 | gap: 1,
|
|---|
| 497 | justifyContent: 'flex-end',
|
|---|
| 498 | alignItems: 'center'
|
|---|
| 499 | }}>
|
|---|
| 500 | {slot.component && (
|
|---|
| 501 | <>
|
|---|
| 502 | {(slot.type === 'memory' || slot.type === 'storage') && (
|
|---|
| 503 | <>
|
|---|
| 504 | <IconButton
|
|---|
| 505 | size="small"
|
|---|
| 506 | color="primary"
|
|---|
| 507 | onClick={() => handleDecrementComponent(slot.id)}
|
|---|
| 508 | disabled={(slot.component.quantity || 1) <= 1}
|
|---|
| 509 | sx={{
|
|---|
| 510 | bgcolor: 'action.hover',
|
|---|
| 511 | '&:disabled': {bgcolor: 'action.disabledBackground'}
|
|---|
| 512 | }}
|
|---|
| 513 | >
|
|---|
| 514 | <RemoveIcon/>
|
|---|
| 515 | </IconButton>
|
|---|
| 516 |
|
|---|
| 517 | <Typography
|
|---|
| 518 | variant="body2"
|
|---|
| 519 | sx={{
|
|---|
| 520 | minWidth: '20px',
|
|---|
| 521 | textAlign: 'center',
|
|---|
| 522 | fontWeight: 'bold'
|
|---|
| 523 | }}
|
|---|
| 524 | >
|
|---|
| 525 | {slot.component.quantity || 1}
|
|---|
| 526 | </Typography>
|
|---|
| 527 |
|
|---|
| 528 | <IconButton
|
|---|
| 529 | size="small"
|
|---|
| 530 | color="primary"
|
|---|
| 531 | onClick={() => handleIncrementComponent(slot.id)}
|
|---|
| 532 | sx={{bgcolor: 'action.hover'}}
|
|---|
| 533 | >
|
|---|
| 534 | <AddIcon/>
|
|---|
| 535 | </IconButton>
|
|---|
| 536 | </>
|
|---|
| 537 | )}
|
|---|
| 538 |
|
|---|
| 539 | <IconButton
|
|---|
| 540 | color="error"
|
|---|
| 541 | onClick={() => handleRemovePart(slot.id)}
|
|---|
| 542 | >
|
|---|
| 543 | <DeleteIcon/>
|
|---|
| 544 | </IconButton>
|
|---|
| 545 | </>
|
|---|
| 546 | )}
|
|---|
| 547 |
|
|---|
| 548 | {!slot.required && (
|
|---|
| 549 | <IconButton
|
|---|
| 550 | color="warning"
|
|---|
| 551 | onClick={() => handleDeleteSlot(slot.id)}
|
|---|
| 552 | >
|
|---|
| 553 | <CloseIcon/>
|
|---|
| 554 | </IconButton>
|
|---|
| 555 | )}
|
|---|
| 556 | </Box>
|
|---|
| 557 | </TableCell>
|
|---|
| 558 | </TableRow>
|
|---|
| 559 | ))}
|
|---|
| 560 | </TableBody>
|
|---|
| 561 | </Table>
|
|---|
| 562 | </TableContainer>
|
|---|
| 563 |
|
|---|
| 564 | <Box sx={{mt: 4, display: 'flex', justifyContent: 'center'}}>
|
|---|
| 565 | <Button variant="outlined" startIcon={<AddIcon/>} onClick={(e) => setAnchorEl(e.currentTarget)}
|
|---|
| 566 | sx={{mr: 2}}>
|
|---|
| 567 | Add Optional Component
|
|---|
| 568 | </Button>
|
|---|
| 569 | <Menu
|
|---|
| 570 | anchorEl={anchorEl}
|
|---|
| 571 | open={Boolean(anchorEl)}
|
|---|
| 572 | onClose={() => setAnchorEl(null)}
|
|---|
| 573 | anchorReference="none"
|
|---|
| 574 | sx={{
|
|---|
| 575 | display: 'flex',
|
|---|
| 576 | alignItems: 'center',
|
|---|
| 577 | justifyContent: 'center',
|
|---|
| 578 | }}
|
|---|
| 579 | slotProps={{
|
|---|
| 580 | paper: {
|
|---|
| 581 | sx: {
|
|---|
| 582 | position: 'absolute',
|
|---|
| 583 | top: '46%',
|
|---|
| 584 | // left: '50%',
|
|---|
| 585 | // transform: 'translate(50%, +50%)',
|
|---|
| 586 | }
|
|---|
| 587 | }
|
|---|
| 588 | }}
|
|---|
| 589 | >
|
|---|
| 590 | {/*Removed RAM & Storage from optional components*/}
|
|---|
| 591 | <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary'}}>
|
|---|
| 592 | Accessories
|
|---|
| 593 | </Typography>
|
|---|
| 594 | <MenuItem onClick={() => handleAddSlot('optical_drive', 'Optical Drive')}>
|
|---|
| 595 | <ListItemIcon><AlbumIcon/></ListItemIcon>
|
|---|
| 596 | Optical Drive
|
|---|
| 597 | </MenuItem>
|
|---|
| 598 | <MenuItem onClick={() => handleAddSlot('cables', 'Cable')}>
|
|---|
| 599 | <ListItemIcon><CableIcon/></ListItemIcon>
|
|---|
| 600 | Cables
|
|---|
| 601 | </MenuItem>
|
|---|
| 602 |
|
|---|
| 603 | <Typography sx={{px: 2, py: 1, display: 'block', color: 'text.secondary', mt: 1}}>
|
|---|
| 604 | Expansion Cards
|
|---|
| 605 | </Typography>
|
|---|
| 606 | <MenuItem onClick={() => handleAddSlot('memory_card', 'Storage Card')}>
|
|---|
| 607 | <ListItemIcon><MemoryIcon/></ListItemIcon>
|
|---|
| 608 | Storage Card
|
|---|
| 609 | </MenuItem>
|
|---|
| 610 | <MenuItem onClick={() => handleAddSlot('sound_card', 'Sound Card')}>
|
|---|
| 611 | <ListItemIcon><RouterIcon/></ListItemIcon>
|
|---|
| 612 | Sound Card
|
|---|
| 613 | </MenuItem>
|
|---|
| 614 | <MenuItem onClick={() => handleAddSlot('network_card', 'Network Card')}>
|
|---|
| 615 | <ListItemIcon><RouterIcon/></ListItemIcon>
|
|---|
| 616 | Network Card
|
|---|
| 617 | </MenuItem>
|
|---|
| 618 | <MenuItem onClick={() => handleAddSlot('network_adapter', 'WiFi Adapter')}>
|
|---|
| 619 | <ListItemIcon><RouterIcon/></ListItemIcon>
|
|---|
| 620 | WiFi Adapter
|
|---|
| 621 | </MenuItem>
|
|---|
| 622 | </Menu>
|
|---|
| 623 | </Box>
|
|---|
| 624 |
|
|---|
| 625 | <Box sx={{mt: 4, p: 4, bgcolor: '#1e1e1e', textAlign: 'center', borderRadius: 2}}>
|
|---|
| 626 | <Typography variant="h5" sx={{mb: 2, fontWeight: 'bold', color: 'white'}}>
|
|---|
| 627 | Total: ${totalPrice.toFixed(2)}
|
|---|
| 628 | </Typography>
|
|---|
| 629 | <Button
|
|---|
| 630 | variant="contained"
|
|---|
| 631 | color="primary"
|
|---|
| 632 | size="large"
|
|---|
| 633 | onClick={handleSubmit}
|
|---|
| 634 | disabled={isSubmitting}
|
|---|
| 635 | >
|
|---|
| 636 | {isSubmitting ? <CircularProgress size={24}/> : 'Save Build'}
|
|---|
| 637 | </Button>
|
|---|
| 638 | </Box>
|
|---|
| 639 |
|
|---|
| 640 | <ComponentDialog
|
|---|
| 641 | open={browserOpen}
|
|---|
| 642 | category={activeSlotType}
|
|---|
| 643 | onClose={() => {
|
|---|
| 644 | setBrowserOpen(false);
|
|---|
| 645 | setActiveSlotId(null);
|
|---|
| 646 | }}
|
|---|
| 647 | mode="forge"
|
|---|
| 648 | onSelect={handleSelectComponent}
|
|---|
| 649 | currentBuildId={buildId}
|
|---|
| 650 | />
|
|---|
| 651 |
|
|---|
| 652 | <ComponentDetailsDialog
|
|---|
| 653 | open={!!detailsOpen}
|
|---|
| 654 | component={detailsOpen}
|
|---|
| 655 | onClose={() => setDetailsOpen(null)}
|
|---|
| 656 | />
|
|---|
| 657 |
|
|---|
| 658 | <Dialog open={submitDialogOpen} onClose={() => setSubmitDialogOpen(false)} maxWidth="sm" fullWidth>
|
|---|
| 659 | <DialogTitle sx={{bgcolor: '#ff8201', color: 'white', fontWeight: 'bold'}}>
|
|---|
| 660 | Build Description
|
|---|
| 661 | </DialogTitle>
|
|---|
| 662 | <DialogContent sx={{p: 3}}>
|
|---|
| 663 | <Typography variant="body1" sx={{mb: 2, color: 'text.secondary'}}>
|
|---|
| 664 | Add some notes about your build (optional):
|
|---|
| 665 | </Typography>
|
|---|
| 666 | <TextField
|
|---|
| 667 | fullWidth
|
|---|
| 668 | multiline
|
|---|
| 669 | rows={4}
|
|---|
| 670 | placeholder="e.g. Workstation monster, great for crunching numbers!"
|
|---|
| 671 | value={tempDescription}
|
|---|
| 672 | onChange={(e) => setTempDescription(e.target.value)}
|
|---|
| 673 | variant="outlined"
|
|---|
| 674 | />
|
|---|
| 675 | </DialogContent>
|
|---|
| 676 | <DialogActions sx={{p: 3, pt: 0}}>
|
|---|
| 677 | <Button onClick={() => setSubmitDialogOpen(false)}>
|
|---|
| 678 | Cancel
|
|---|
| 679 | </Button>
|
|---|
| 680 | <Button
|
|---|
| 681 | variant="contained"
|
|---|
| 682 | color="primary"
|
|---|
| 683 | onClick={handleSubmitConfirm}
|
|---|
| 684 | disabled={isSubmitting}
|
|---|
| 685 | >
|
|---|
| 686 | {isSubmitting ? (
|
|---|
| 687 | <>
|
|---|
| 688 | <CircularProgress size={20} sx={{mr: 1}}/>
|
|---|
| 689 | Submitting...
|
|---|
| 690 | </>
|
|---|
| 691 | ) : (
|
|---|
| 692 | 'Submit Build'
|
|---|
| 693 | )}
|
|---|
| 694 | </Button>
|
|---|
| 695 | </DialogActions>
|
|---|
| 696 | </Dialog>
|
|---|
| 697 | <Snackbar
|
|---|
| 698 | open={snackbar.open}
|
|---|
| 699 | autoHideDuration={4000}
|
|---|
| 700 | onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
|---|
| 701 | anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
|
|---|
| 702 | >
|
|---|
| 703 | <Alert
|
|---|
| 704 | onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
|---|
| 705 | severity={snackbar.severity}
|
|---|
| 706 | variant="filled"
|
|---|
| 707 | sx={{width: '100%'}}
|
|---|
| 708 | >
|
|---|
| 709 | {snackbar.message}
|
|---|
| 710 | </Alert>
|
|---|
| 711 | </Snackbar>
|
|---|
| 712 | </Container>
|
|---|
| 713 | );
|
|---|
| 714 | } |
|---|