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