Dev #16
38
src/Imprink.Application/Commands/Orders/GetMyOrders.cs
Normal file
38
src/Imprink.Application/Commands/Orders/GetMyOrders.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using AutoMapper;
|
||||
using Imprink.Application.Dtos;
|
||||
using Imprink.Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Imprink.Application.Commands.Orders;
|
||||
|
||||
public class GetOrdersByUserIdQuery : IRequest<IEnumerable<OrderDto>>
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public bool IncludeDetails { get; set; }
|
||||
}
|
||||
|
||||
public class GetOrdersByUserId(
|
||||
IUnitOfWork uw,
|
||||
IMapper mapper)
|
||||
: IRequestHandler<GetOrdersByUserIdQuery, IEnumerable<OrderDto>>
|
||||
{
|
||||
public async Task<IEnumerable<OrderDto>> Handle(
|
||||
GetOrdersByUserIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Order> orders;
|
||||
|
||||
if (request.IncludeDetails)
|
||||
{
|
||||
orders = await uw.OrderRepository
|
||||
.GetByUserIdWithDetailsAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
orders = await uw.OrderRepository
|
||||
.GetByUserIdAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
return mapper.Map<IEnumerable<OrderDto>>(orders);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Application.Commands.Orders;
|
||||
|
||||
public class SetOrderStatus
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Application.Commands.Orders;
|
||||
|
||||
public class SetOrderShippingStatus
|
||||
{
|
||||
|
||||
}
|
||||
6
src/Imprink.Application/Dtos/EnumValueDto.cs
Normal file
6
src/Imprink.Application/Dtos/EnumValueDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Application.Dtos;
|
||||
|
||||
public class EnumValueDto
|
||||
{
|
||||
|
||||
}
|
||||
6
src/Imprink.Application/Dtos/Orders/StatusDto.cs
Normal file
6
src/Imprink.Application/Dtos/Orders/StatusDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Application.Dtos;
|
||||
|
||||
public class StatusDto
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using AutoMapper;
|
||||
using Imprink.Application.Dtos;
|
||||
using Imprink.Domain.Entities;
|
||||
|
||||
namespace Imprink.Application.Mappings;
|
||||
|
||||
public class OrderStatusMappingProfile : Profile
|
||||
{
|
||||
public OrderStatusMappingProfile()
|
||||
{
|
||||
CreateMap<OrderStatus, OrderStatusDto>();
|
||||
|
||||
CreateMap<OrderStatusDto, OrderStatus>();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using AutoMapper;
|
||||
using Imprink.Application.Dtos;
|
||||
using Imprink.Domain.Entities;
|
||||
|
||||
namespace Imprink.Application.Mappings;
|
||||
|
||||
public class ShippingStatusMappingProfile : Profile
|
||||
{
|
||||
public ShippingStatusMappingProfile()
|
||||
{
|
||||
CreateMap<ShippingStatus, ShippingStatusDto>();
|
||||
|
||||
CreateMap<ShippingStatusDto, ShippingStatus>();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Imprink.Domain.Entities;
|
||||
|
||||
public class OrderStatus
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Imprink.Domain.Entities;
|
||||
|
||||
public class ShippingStatus
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
|
||||
}
|
||||
6
src/Imprink.Domain/Enums/OrderStatusEnum.cs
Normal file
6
src/Imprink.Domain/Enums/OrderStatusEnum.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Domain.Enums;
|
||||
|
||||
public enum OrderStatusEnum
|
||||
{
|
||||
|
||||
}
|
||||
6
src/Imprink.Domain/Enums/ShippingStatusEnum.cs
Normal file
6
src/Imprink.Domain/Enums/ShippingStatusEnum.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Domain.Enums;
|
||||
|
||||
public enum ShippingStatusEnum
|
||||
{
|
||||
|
||||
}
|
||||
6
src/Imprink.Domain/Extensions/StatusExtensions.cs
Normal file
6
src/Imprink.Domain/Extensions/StatusExtensions.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Imprink.Domain.Extensions;
|
||||
|
||||
public class StatusExtensions
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Imprink.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Imprink.Infrastructure.Configuration;
|
||||
|
||||
public class OrderStatusConfiguration : IEntityTypeConfiguration<OrderStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OrderStatus> builder)
|
||||
{
|
||||
builder.HasKey(os => os.Id);
|
||||
|
||||
builder.Property(os => os.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(os => os.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(os => os.Name)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_OrderStatus_Name");
|
||||
|
||||
builder.HasData(
|
||||
new OrderStatus { Id = 0, Name = "Pending" },
|
||||
new OrderStatus { Id = 1, Name = "Processing" },
|
||||
new OrderStatus { Id = 2, Name = "Completed" },
|
||||
new OrderStatus { Id = 3, Name = "Cancelled" }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Imprink.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Imprink.Infrastructure.Configuration;
|
||||
|
||||
public class ShippingStatusConfiguration : IEntityTypeConfiguration<ShippingStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ShippingStatus> builder)
|
||||
{
|
||||
builder.HasKey(ss => ss.Id);
|
||||
|
||||
builder.Property(ss => ss.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(ss => ss.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(ss => ss.Name)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShippingStatus_Name");
|
||||
|
||||
builder.HasData(
|
||||
new ShippingStatus { Id = 0, Name = "Prepping" },
|
||||
new ShippingStatus { Id = 1, Name = "Packaging" },
|
||||
new ShippingStatus { Id = 2, Name = "Shipped" },
|
||||
new ShippingStatus { Id = 3, Name = "Delivered" }
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,676 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Imprink.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSetup : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Categories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
ImageUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
ParentCategoryId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Categories", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Categories_Categories_ParentCategoryId",
|
||||
column: x => x.ParentCategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrderStatuses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrderStatuses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Roles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RoleName = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Roles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShippingStatuses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShippingStatuses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Nickname = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
EmailVerified = table.Column<bool>(type: "bit", nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Products",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
BasePrice = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsCustomizable = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
ImageUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CategoryId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Products", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Products_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Addresses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
AddressType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
Company = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
AddressLine1 = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
AddressLine2 = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
ApartmentNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
BuildingNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Floor = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
City = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
PostalCode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Country = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
PhoneNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Instructions = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Addresses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Addresses_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserRole",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserRole", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserRole_Roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "Roles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserRole_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductVariants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Size = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Color = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Price = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
ImageUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Sku = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
StockQuantity = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductVariants", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductVariants_Products_ProductId",
|
||||
column: x => x.ProductId,
|
||||
principalTable: "Products",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Orders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
OrderDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Quantity = table.Column<int>(type: "int", nullable: false, defaultValue: 1),
|
||||
ProductId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ProductVariantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
OrderStatusId = table.Column<int>(type: "int", nullable: false),
|
||||
ShippingStatusId = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
MerchantId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
CustomizationImageUrl = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
OriginalImageUrls = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CustomizationDescription = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Orders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_OrderStatuses_OrderStatusId",
|
||||
column: x => x.OrderStatusId,
|
||||
principalTable: "OrderStatuses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_ProductVariants_ProductVariantId",
|
||||
column: x => x.ProductVariantId,
|
||||
principalTable: "ProductVariants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_Products_ProductId",
|
||||
column: x => x.ProductId,
|
||||
principalTable: "Products",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_ShippingStatuses_ShippingStatusId",
|
||||
column: x => x.ShippingStatusId,
|
||||
principalTable: "ShippingStatuses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_Users_MerchantId",
|
||||
column: x => x.MerchantId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Orders_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrderAddresses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"),
|
||||
OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AddressType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
Company = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
AddressLine1 = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
AddressLine2 = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
ApartmentNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
BuildingNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Floor = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
City = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
PostalCode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
Country = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
PhoneNumber = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Instructions = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true, defaultValueSql: "GETUTCDATE()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrderAddresses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OrderAddresses_Orders_OrderId",
|
||||
column: x => x.OrderId,
|
||||
principalTable: "Orders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Categories",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedBy", "Description", "ImageUrl", "IsActive", "ModifiedAt", "ModifiedBy", "Name", "ParentCategoryId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("11111111-1111-1111-1111-111111111111"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Textile and fabric-based products", null, true, new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Textile", null, 1 },
|
||||
{ new Guid("22222222-2222-2222-2222-222222222222"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Products for hard surface printing", null, true, new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Hard Surfaces", null, 2 },
|
||||
{ new Guid("33333333-3333-3333-3333-333333333333"), new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Paper-based printing products", null, true, new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "system@printbase.com", "Paper", null, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrderStatuses",
|
||||
columns: new[] { "Id", "Name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 0, "Pending" },
|
||||
{ 1, "Processing" },
|
||||
{ 2, "Completed" },
|
||||
{ 3, "Cancelled" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Roles",
|
||||
columns: new[] { "Id", "RoleName" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("22222222-2222-2222-2222-222222222222"), "Merchant" },
|
||||
{ new Guid("33333333-3333-3333-3333-333333333333"), "Admin" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ShippingStatuses",
|
||||
columns: new[] { "Id", "Name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 0, "Prepping" },
|
||||
{ 1, "Packaging" },
|
||||
{ 2, "Shipped" },
|
||||
{ 3, "Delivered" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_CreatedAt",
|
||||
table: "Addresses",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_CreatedBy",
|
||||
table: "Addresses",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_ModifiedAt",
|
||||
table: "Addresses",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_User_Default",
|
||||
table: "Addresses",
|
||||
columns: new[] { "UserId", "IsDefault" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_User_Type",
|
||||
table: "Addresses",
|
||||
columns: new[] { "UserId", "AddressType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Address_UserId",
|
||||
table: "Addresses",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_Active_SortOrder",
|
||||
table: "Categories",
|
||||
columns: new[] { "IsActive", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_CreatedAt",
|
||||
table: "Categories",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_CreatedBy",
|
||||
table: "Categories",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_IsActive",
|
||||
table: "Categories",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_ModifiedAt",
|
||||
table: "Categories",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_Name",
|
||||
table: "Categories",
|
||||
column: "Name");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_Parent_SortOrder",
|
||||
table: "Categories",
|
||||
columns: new[] { "ParentCategoryId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Category_ParentCategoryId",
|
||||
table: "Categories",
|
||||
column: "ParentCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderAddress_CreatedAt",
|
||||
table: "OrderAddresses",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderAddress_CreatedBy",
|
||||
table: "OrderAddresses",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderAddress_ModifiedAt",
|
||||
table: "OrderAddresses",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderAddress_OrderId",
|
||||
table: "OrderAddresses",
|
||||
column: "OrderId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_CreatedAt",
|
||||
table: "Orders",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_CreatedBy",
|
||||
table: "Orders",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_Merchant_Date",
|
||||
table: "Orders",
|
||||
columns: new[] { "MerchantId", "OrderDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_MerchantId",
|
||||
table: "Orders",
|
||||
column: "MerchantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_ModifiedAt",
|
||||
table: "Orders",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_OrderDate",
|
||||
table: "Orders",
|
||||
column: "OrderDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_OrderStatusId",
|
||||
table: "Orders",
|
||||
column: "OrderStatusId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_Product_Date",
|
||||
table: "Orders",
|
||||
columns: new[] { "ProductId", "OrderDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_ProductId",
|
||||
table: "Orders",
|
||||
column: "ProductId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_ProductVariantId",
|
||||
table: "Orders",
|
||||
column: "ProductVariantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_ShippingStatusId",
|
||||
table: "Orders",
|
||||
column: "ShippingStatusId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_User_Date",
|
||||
table: "Orders",
|
||||
columns: new[] { "UserId", "OrderDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Order_UserId",
|
||||
table: "Orders",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderStatus_Name",
|
||||
table: "OrderStatuses",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_Active_Customizable",
|
||||
table: "Products",
|
||||
columns: new[] { "IsActive", "IsCustomizable" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_Category_Active",
|
||||
table: "Products",
|
||||
columns: new[] { "CategoryId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_CategoryId",
|
||||
table: "Products",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_CreatedAt",
|
||||
table: "Products",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_CreatedBy",
|
||||
table: "Products",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_IsActive",
|
||||
table: "Products",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_IsCustomizable",
|
||||
table: "Products",
|
||||
column: "IsCustomizable");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_ModifiedAt",
|
||||
table: "Products",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Product_Name",
|
||||
table: "Products",
|
||||
column: "Name");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_CreatedAt",
|
||||
table: "ProductVariants",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_CreatedBy",
|
||||
table: "ProductVariants",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_IsActive",
|
||||
table: "ProductVariants",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_ModifiedAt",
|
||||
table: "ProductVariants",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_Product_Size_Color",
|
||||
table: "ProductVariants",
|
||||
columns: new[] { "ProductId", "Size", "Color" },
|
||||
unique: true,
|
||||
filter: "[Color] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_ProductId",
|
||||
table: "ProductVariants",
|
||||
column: "ProductId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductVariant_SKU",
|
||||
table: "ProductVariants",
|
||||
column: "Sku",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Role_RoleName",
|
||||
table: "Roles",
|
||||
column: "RoleName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShippingStatus_Name",
|
||||
table: "ShippingStatuses",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserRole_RoleId",
|
||||
table: "UserRole",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserRole_UserId",
|
||||
table: "UserRole",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_User_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_User_IsActive",
|
||||
table: "Users",
|
||||
column: "IsActive");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Addresses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrderAddresses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserRole");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Orders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Roles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrderStatuses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductVariants");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ShippingStatuses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Products");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Categories");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1411
ui/package-lock.json
generated
1411
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,18 @@
|
||||
"@mui/material": "^7.1.2",
|
||||
"@mui/material-nextjs": "^7.1.1",
|
||||
"@mui/system": "^7.1.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@types/fabric": "^5.3.10",
|
||||
"axios": "^1.10.0",
|
||||
"fabric": "^6.7.0",
|
||||
"formik": "^2.4.6",
|
||||
"konva": "^9.3.20",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-konva": "^19.0.6",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -2,126 +2,44 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import clientApi from '@/lib/clientApi';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Grid,
|
||||
TextField,
|
||||
IconButton,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Container,
|
||||
Paper,
|
||||
Fade,
|
||||
CircularProgress
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Remove as RemoveIcon,
|
||||
LocationOn as LocationIcon,
|
||||
AddLocation as AddLocationIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Product, Variant, Address, NewAddress } from '@/types';
|
||||
import StepperHeader from '@/app/components/orderbuilder/StepperHeader';
|
||||
import AddAddressDialog from '@/app/components/orderbuilder/AddAddressDialog';
|
||||
import StepProductDetails from '@/app/components/orderbuilder/StepProductDetails';
|
||||
import StepSelectVariant from '@/app/components/orderbuilder/StepSelectVariant';
|
||||
import StepCustomization from '@/app/components/orderbuilder/StepCustomization';
|
||||
import StepChooseQuantity from '@/app/components/orderbuilder/StepChooseQuantity';
|
||||
import StepDeliveryAddress from '@/app/components/orderbuilder/StepDeliveryAddress';
|
||||
import StepReviewOrder from '@/app/components/orderbuilder/StepReviewOrder';
|
||||
import StepPayment from '@/app/components/orderbuilder/StepPayment';
|
||||
|
||||
interface Category {
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
parentCategoryId: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
isCustomizable: boolean;
|
||||
isActive: boolean;
|
||||
imageUrl: string;
|
||||
categoryId: string;
|
||||
category: Category;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
|
||||
interface Variant {
|
||||
id: string;
|
||||
productId: string;
|
||||
size: string;
|
||||
color: string;
|
||||
price: number;
|
||||
imageUrl: string;
|
||||
sku: string;
|
||||
stockQuantity: number;
|
||||
isActive: boolean;
|
||||
product: Product;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
addressType: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
apartmentNumber: string;
|
||||
buildingNumber: string;
|
||||
floor: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phoneNumber: string;
|
||||
instructions: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface NewAddress {
|
||||
addressType: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
apartmentNumber: string;
|
||||
buildingNumber: string;
|
||||
floor: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phoneNumber: string;
|
||||
instructions: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const steps = ['Product Details', 'Select Variant', 'Choose Quantity', 'Delivery Address', 'Review & Order'];
|
||||
const steps = [
|
||||
'Product Details',
|
||||
'Select Variant',
|
||||
'Customization',
|
||||
'Choose Quantity',
|
||||
'Delivery Address',
|
||||
'Review & Order',
|
||||
'Payment'
|
||||
];
|
||||
|
||||
export default function OrderBuilder() {
|
||||
const router = useRouter();
|
||||
@@ -137,6 +55,11 @@ export default function OrderBuilder() {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedAddress, setSelectedAddress] = useState<Address | null>(null);
|
||||
const [showAddressDialog, setShowAddressDialog] = useState(false);
|
||||
const [customizationImages, setCustomizationImages] = useState<CustomizationImage[]>([]);
|
||||
const [finalImageUrl, setFinalImageUrl] = useState('');
|
||||
const [customizationDescription, setCustomizationDescription] = useState('');
|
||||
const [orderId, setOrderId] = useState<string>('');
|
||||
const [clientSecret, setClientSecret] = useState<string>('');
|
||||
const [newAddress, setNewAddress] = useState<NewAddress>({
|
||||
addressType: 'Home',
|
||||
firstName: '',
|
||||
@@ -154,22 +77,18 @@ export default function OrderBuilder() {
|
||||
phoneNumber: '',
|
||||
instructions: '',
|
||||
isDefault: false,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
loadProduct();
|
||||
}
|
||||
if (productId) loadProduct();
|
||||
}, [productId]);
|
||||
|
||||
const loadProduct = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const productData = await clientApi.get(`/products/${productId}`);
|
||||
setProduct(productData.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load product:', error);
|
||||
const { data } = await clientApi.get(`/products/${productId}`);
|
||||
setProduct(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -178,10 +97,8 @@ export default function OrderBuilder() {
|
||||
const loadVariants = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const variantsData = await clientApi.get(`/products/variants/${productId}`);
|
||||
setVariants(variantsData.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load variants:', error);
|
||||
const { data } = await clientApi.get(`/products/variants/${productId}`);
|
||||
setVariants(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -190,47 +107,52 @@ export default function OrderBuilder() {
|
||||
const loadAddresses = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const addressesData = await clientApi.get('/addresses/me');
|
||||
setAddresses(addressesData.data);
|
||||
if (addressesData.data.length > 0) {
|
||||
const defaultAddress = addressesData.data.find((addr: Address) => addr.isDefault) || addressesData.data[0];
|
||||
setSelectedAddress(defaultAddress);
|
||||
console.log('Loading addresses...');
|
||||
const response = await clientApi.get('/addresses/me');
|
||||
console.log('API Response:', response);
|
||||
console.log('API Data:', response.data);
|
||||
|
||||
const data = response.data;
|
||||
setAddresses(data);
|
||||
|
||||
if (data.length > 0) {
|
||||
const defaultAddr = data.find((addr: Address) => addr.isDefault) || data[0];
|
||||
setSelectedAddress(defaultAddr);
|
||||
console.log('Selected address:', defaultAddr);
|
||||
} else {
|
||||
console.log('No addresses found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load addresses:', error);
|
||||
console.error('Error loading addresses:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (activeStep === 0 && product) {
|
||||
loadVariants();
|
||||
} else if (activeStep === 2) {
|
||||
loadAddresses();
|
||||
} else if (activeStep === 2 && product?.isCustomizable && customizationImages.length > 0 && !finalImageUrl) {
|
||||
return;
|
||||
} else if (activeStep === 3) {
|
||||
await loadAddresses();
|
||||
}
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||
};
|
||||
setActiveStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||
};
|
||||
const handleBack = () => setActiveStep((prev) => prev - 1);
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = quantity + delta;
|
||||
if (newQuantity >= 1) {
|
||||
setQuantity(newQuantity);
|
||||
}
|
||||
setQuantity((prev) => Math.max(1, prev + delta));
|
||||
};
|
||||
|
||||
const handleAddAddress = async () => {
|
||||
try {
|
||||
const addedAddress = await clientApi.post('/addresses', newAddress);
|
||||
setAddresses([...addresses, addedAddress.data]);
|
||||
setSelectedAddress(addedAddress.data);
|
||||
const added = await clientApi.post('/addresses', newAddress);
|
||||
setAddresses([...addresses, added.data]);
|
||||
setSelectedAddress(added.data);
|
||||
setShowAddressDialog(false);
|
||||
setNewAddress({
|
||||
addressType: 'shipping',
|
||||
addressType: 'Home',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
company: '',
|
||||
@@ -246,376 +168,188 @@ export default function OrderBuilder() {
|
||||
phoneNumber: '',
|
||||
instructions: '',
|
||||
isDefault: false,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add address:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!selectedVariant || !selectedAddress) return;
|
||||
|
||||
const orderData = {
|
||||
quantity,
|
||||
productId: product!.id,
|
||||
productVariantId: selectedVariant.id,
|
||||
quantity: quantity,
|
||||
originalImageUrls: customizationImages.map(img => img.url),
|
||||
customizationImageUrl: finalImageUrl,
|
||||
customizationDescription,
|
||||
addressId: selectedAddress.id,
|
||||
totalPrice: selectedVariant.price * quantity
|
||||
};
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await clientApi.post('/orders', orderData);
|
||||
router.push('/orders/success');
|
||||
try {
|
||||
const orderResponse = await clientApi.post('/orders', orderData);
|
||||
const newOrderId = orderResponse.data.id;
|
||||
setOrderId(newOrderId);
|
||||
|
||||
const paymentResponse = await clientApi.post('/stripe/create-payment-intent', {
|
||||
orderId: newOrderId
|
||||
});
|
||||
|
||||
setClientSecret(paymentResponse.data.clientSecret);
|
||||
setActiveStep((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to place order:', error);
|
||||
console.error('Error creating order or payment intent:', error);
|
||||
alert('Failed to create order. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalPrice = () => {
|
||||
if (!selectedVariant) return 0;
|
||||
return selectedVariant.price * quantity;
|
||||
const handlePaymentSuccess = () => {
|
||||
router.push(`/orders/success?orderId=${orderId}`);
|
||||
};
|
||||
|
||||
const getTotalPrice = () => (selectedVariant ? selectedVariant.price * quantity : 0);
|
||||
|
||||
const canProceed = () => {
|
||||
switch (activeStep) {
|
||||
case 0: return product !== null;
|
||||
case 1: return selectedVariant !== null;
|
||||
case 2: return quantity > 0;
|
||||
case 3: return selectedAddress !== null;
|
||||
case 2:
|
||||
if (!product?.isCustomizable) return true;
|
||||
return customizationImages.length === 0 || finalImageUrl !== '';
|
||||
case 3: return quantity > 0;
|
||||
case 4: return selectedAddress !== null;
|
||||
case 5: return true;
|
||||
case 6: return false;
|
||||
default: return true;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowCustomization = () => {
|
||||
return product?.isCustomizable;
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
{product && (
|
||||
<Card>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="400"
|
||||
image={product.imageUrl}
|
||||
alt={product.name}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
{product.description}
|
||||
</Typography>
|
||||
<Typography variant="h5" color="primary">
|
||||
${product.basePrice.toFixed(2)}
|
||||
</Typography>
|
||||
<Box mt={2}>
|
||||
<Chip label={product.category.name} color="primary" variant="outlined" />
|
||||
{product.isCustomizable && <Chip label="Customizable" color="secondary" sx={{ ml: 1 }} />}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
return <StepProductDetails product={product} />;
|
||||
case 1:
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Select Variant
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{variants.map((variant) => (
|
||||
<Grid size={{ xs:12, sm:6, md:4 }} key={variant.id}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: selectedVariant?.id === variant.id ? 2 : 1,
|
||||
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'grey.300'
|
||||
}}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={variant.imageUrl}
|
||||
alt={`${variant.size} - ${variant.color}`}
|
||||
<StepSelectVariant
|
||||
variants={variants}
|
||||
selectedVariant={selectedVariant}
|
||||
setSelectedVariant={setSelectedVariant}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="h6">
|
||||
{variant.size} - {variant.color}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
SKU: {variant.sku}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
${variant.price.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color={variant.stockQuantity > 0 ? 'success.main' : 'error.main'}>
|
||||
{variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
case 2:
|
||||
if (!shouldShowCustomization()) {
|
||||
setActiveStep(3);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Choose Quantity
|
||||
</Typography>
|
||||
{selectedVariant && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs:12, md:6 }}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<img
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||||
<StepCustomization
|
||||
images={customizationImages}
|
||||
setImages={setCustomizationImages}
|
||||
finalImageUrl={finalImageUrl}
|
||||
setFinalImageUrl={setFinalImageUrl}
|
||||
customizationDescription={customizationDescription}
|
||||
setCustomizationDescription={setCustomizationDescription}
|
||||
loading={loading}
|
||||
setLoading={setLoading}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
${selectedVariant.price.toFixed(2)} each
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, md:6 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={2}>
|
||||
<IconButton
|
||||
onClick={() => handleQuantityChange(-1)}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 1;
|
||||
if (val >= 1) setQuantity(val);
|
||||
}}
|
||||
inputProps={{
|
||||
style: { textAlign: 'center', fontSize: '1.2rem' },
|
||||
min: 1
|
||||
}}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
<IconButton onClick={() => handleQuantityChange(1)}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box mt={3} textAlign="center">
|
||||
<Typography variant="h4" color="primary">
|
||||
Total: ${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Select Delivery Address
|
||||
</Typography>
|
||||
<FormControl component="fieldset" sx={{ width: '100%' }}>
|
||||
<RadioGroup
|
||||
value={selectedAddress?.id || ''}
|
||||
onChange={(e) => {
|
||||
const addr = addresses.find(a => a.id === e.target.value);
|
||||
setSelectedAddress(addr || null);
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
{addresses.map((address) => (
|
||||
<Grid size={{ xs:12, md:6 }} key={address.id}>
|
||||
<Card sx={{ position: 'relative' }}>
|
||||
<CardContent>
|
||||
<FormControlLabel
|
||||
value={address.id}
|
||||
control={<Radio />}
|
||||
label=""
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
<StepChooseQuantity
|
||||
selectedVariant={selectedVariant}
|
||||
quantity={quantity}
|
||||
handleQuantityChange={handleQuantityChange}
|
||||
getTotalPrice={getTotalPrice}
|
||||
setQuantity={setQuantity}
|
||||
/>
|
||||
<Box display="flex" alignItems="flex-start" gap={1} mb={1}>
|
||||
<LocationIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
{address.firstName} {address.lastName}
|
||||
</Typography>
|
||||
{address.isDefault && <Chip label="Default" size="small" color="primary" />}
|
||||
</Box>
|
||||
{address.company && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{address.company}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{address.addressLine1}
|
||||
</Typography>
|
||||
{address.addressLine2 && (
|
||||
<Typography variant="body2">
|
||||
{address.addressLine2}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{address.city}, {address.state} {address.postalCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{address.country}
|
||||
</Typography>
|
||||
{address.phoneNumber && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Phone: {address.phoneNumber}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
<Grid size={{ xs:12, md:6 }}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '2px dashed',
|
||||
borderColor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200
|
||||
}}
|
||||
onClick={() => setShowAddressDialog(true)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box textAlign="center">
|
||||
<AddLocationIcon sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h6" color="primary">
|
||||
Add New Address
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Review Your Order
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs:12, md:8 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Order Summary
|
||||
</Typography>
|
||||
{selectedVariant && (
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<img
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 8 }}
|
||||
<StepDeliveryAddress
|
||||
addresses={addresses}
|
||||
selectedAddress={selectedAddress}
|
||||
setSelectedAddress={setSelectedAddress}
|
||||
setShowAddressDialog={setShowAddressDialog}
|
||||
/>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle1">
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Quantity: {quantity}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h6">
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, md:4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Delivery Address
|
||||
</Typography>
|
||||
{selectedAddress && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2">
|
||||
{selectedAddress.firstName} {selectedAddress.lastName}
|
||||
</Typography>
|
||||
{selectedAddress.company && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedAddress.company}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine1}
|
||||
</Typography>
|
||||
{selectedAddress.addressLine2 && (
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine2}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.city}, {selectedAddress.state} {selectedAddress.postalCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.country}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
case 5:
|
||||
return (
|
||||
<StepReviewOrder
|
||||
selectedVariant={selectedVariant}
|
||||
quantity={quantity}
|
||||
getTotalPrice={getTotalPrice}
|
||||
selectedAddress={selectedAddress}
|
||||
customizationImages={customizationImages}
|
||||
finalImageUrl={finalImageUrl}
|
||||
customizationDescription={customizationDescription}
|
||||
/>
|
||||
);
|
||||
case 6:
|
||||
if (clientSecret) {
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#1976d2',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#30313d',
|
||||
colorDanger: '#df1b41',
|
||||
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StepPayment
|
||||
orderId={orderId}
|
||||
selectedVariant={selectedVariant}
|
||||
quantity={quantity}
|
||||
getTotalPrice={getTotalPrice}
|
||||
selectedAddress={selectedAddress}
|
||||
customizationImages={customizationImages}
|
||||
finalImageUrl={finalImageUrl}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepButtonText = () => {
|
||||
if (activeStep === steps.length - 2) return 'Place Order';
|
||||
if (activeStep === steps.length - 1) return 'Payment';
|
||||
return 'Next';
|
||||
};
|
||||
|
||||
const handleStepAction = () => {
|
||||
if (activeStep === steps.length - 2) {
|
||||
handlePlaceOrder();
|
||||
} else if (activeStep === steps.length - 1) {
|
||||
return;
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !product) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -627,174 +361,36 @@ export default function OrderBuilder() {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{renderStepContent()}
|
||||
</Box>
|
||||
<StepperHeader activeStep={activeStep} steps={steps} />
|
||||
<Box sx={{ mb: 4 }}>{renderStepContent()}</Box>
|
||||
|
||||
{activeStep < steps.length - 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
disabled={activeStep === 0}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={activeStep === steps.length - 1 ? handlePlaceOrder : handleNext}
|
||||
onClick={handleStepAction}
|
||||
disabled={!canProceed() || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : activeStep === steps.length - 1 ? (
|
||||
'Place Order'
|
||||
) : (
|
||||
'Next'
|
||||
)}
|
||||
{loading ? <CircularProgress size={24} /> : getStepButtonText()}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Dialog open={showAddressDialog} onClose={() => setShowAddressDialog(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Add New Address</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
value={newAddress.firstName}
|
||||
onChange={(e) => setNewAddress({...newAddress, firstName: e.target.value})}
|
||||
<AddAddressDialog
|
||||
open={showAddressDialog}
|
||||
onClose={() => setShowAddressDialog(false)}
|
||||
onAdd={handleAddAddress}
|
||||
newAddress={newAddress}
|
||||
setNewAddress={setNewAddress}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
value={newAddress.lastName}
|
||||
onChange={(e) => setNewAddress({...newAddress, lastName: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Company (Optional)"
|
||||
value={newAddress.company}
|
||||
onChange={(e) => setNewAddress({...newAddress, company: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Address Line 1"
|
||||
value={newAddress.addressLine1}
|
||||
onChange={(e) => setNewAddress({...newAddress, addressLine1: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Address Line 2 (Optional)"
|
||||
value={newAddress.addressLine2}
|
||||
onChange={(e) => setNewAddress({...newAddress, addressLine2: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Apartment #"
|
||||
value={newAddress.apartmentNumber}
|
||||
onChange={(e) => setNewAddress({...newAddress, apartmentNumber: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Building #"
|
||||
value={newAddress.buildingNumber}
|
||||
onChange={(e) => setNewAddress({...newAddress, buildingNumber: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Floor"
|
||||
value={newAddress.floor}
|
||||
onChange={(e) => setNewAddress({...newAddress, floor: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="City"
|
||||
value={newAddress.city}
|
||||
onChange={(e) => setNewAddress({...newAddress, city: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="State"
|
||||
value={newAddress.state}
|
||||
onChange={(e) => setNewAddress({...newAddress, state: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Postal Code"
|
||||
value={newAddress.postalCode}
|
||||
onChange={(e) => setNewAddress({...newAddress, postalCode: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm:6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Country"
|
||||
value={newAddress.country}
|
||||
onChange={(e) => setNewAddress({...newAddress, country: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Phone Number"
|
||||
value={newAddress.phoneNumber}
|
||||
onChange={(e) => setNewAddress({...newAddress, phoneNumber: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Delivery Instructions (Optional)"
|
||||
value={newAddress.instructions}
|
||||
onChange={(e) => setNewAddress({...newAddress, instructions: e.target.value})}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowAddressDialog(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAddAddress}
|
||||
variant="contained"
|
||||
disabled={!newAddress.firstName || !newAddress.lastName || !newAddress.addressLine1 || !newAddress.city}
|
||||
>
|
||||
Add Address
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -58,14 +58,13 @@ export default function ImprinkAppBar() {
|
||||
const navigationLinks: NavLink[] = [
|
||||
{ label: 'Home', href: '/', icon: <Home />, show: true },
|
||||
{ label: 'Gallery', href: '/gallery', icon: <PhotoLibrary />, show: true },
|
||||
{ label: 'Orders', href: '/orders', icon: <ShoppingBag />, show: true },
|
||||
{ label: 'Orders', href: '/orders', icon: <ShoppingBag />, show: !!user },
|
||||
{ label: 'Merchant', href: '/merchant', icon: <Store />, show: isMerchant },
|
||||
];
|
||||
|
||||
const adminLinks: NavLink[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <Dashboard />, show: isMerchant },
|
||||
{ label: 'Admin', href: '/admin', icon: <AdminPanelSettings />, show: isAdmin },
|
||||
{ label: 'Swagger', href: '/swagger', icon: <Api />, show: isAdmin },
|
||||
{ label: 'SEQ', href: '/seq', icon: <BugReport />, show: isAdmin },
|
||||
];
|
||||
|
||||
@@ -164,14 +163,6 @@ export default function ImprinkAppBar() {
|
||||
<Avatar src={user.picture ?? undefined} alt={user.name ?? 'User'} sx={{ width: 24, height: 24 }} />
|
||||
<Typography variant="body2" noWrap>{user.name}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={e => e.stopPropagation()}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Typography variant="body2">Theme</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<ThemeToggleButton />
|
||||
</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem component="a" href="/auth/logout" onClick={handleMenuClose} sx={{ color: 'error.main' }}>
|
||||
<Typography variant="body2">Logout</Typography>
|
||||
</MenuItem>
|
||||
@@ -184,12 +175,6 @@ export default function ImprinkAppBar() {
|
||||
<MenuItem component="a" href="/auth/login" onClick={handleMenuClose} sx={{ color: 'primary.main', fontWeight: 'bold' }}>
|
||||
<Typography variant="body2">Sign Up</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={toggleTheme}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Typography variant="body2">Theme</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>{isDarkMode ? '🌙' : '☀️'}</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -230,6 +215,11 @@ export default function ImprinkAppBar() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isMobile && (
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<ThemeToggleButton />
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
{renderAdminBar()}
|
||||
|
||||
176
ui/src/app/components/gallery/CategorySidebar.tsx
Normal file
176
ui/src/app/components/gallery/CategorySidebar.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Slider,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@mui/material';
|
||||
import { ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { GalleryCategory, Filters } from '@/types';
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: GalleryCategory[];
|
||||
filters: Filters;
|
||||
expandedCategories: Set<string>;
|
||||
priceRange: number[];
|
||||
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||
onToggleCategoryExpansion: (categoryId: string) => void;
|
||||
onPriceRangeChange: (event: Event, newValue: number | number[]) => void;
|
||||
onPriceRangeCommitted: (event: Event | React.SyntheticEvent, newValue: number | number[]) => void;
|
||||
}
|
||||
|
||||
export default function CategorySidebar({
|
||||
categories,
|
||||
filters,
|
||||
expandedCategories,
|
||||
priceRange,
|
||||
onFilterChange,
|
||||
onToggleCategoryExpansion,
|
||||
onPriceRangeChange,
|
||||
onPriceRangeCommitted
|
||||
}: CategorySidebarProps) {
|
||||
const getChildCategories = (parentId: string): GalleryCategory[] => {
|
||||
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||
};
|
||||
|
||||
const getParentCategories = (): GalleryCategory[] => {
|
||||
return categories.filter(cat => !cat.parentCategoryId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
p: 2,
|
||||
pb: 1,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
px: 2,
|
||||
minHeight: 0
|
||||
}}>
|
||||
<List dense>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={!filters.categoryId}
|
||||
onClick={() => onFilterChange('categoryId', '')}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary="All Products" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{getParentCategories().map((category) => {
|
||||
const childCategories = getChildCategories(category.id);
|
||||
const hasChildren = childCategories.length > 0;
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
|
||||
return (
|
||||
<Box key={category.id}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === category.id}
|
||||
onClick={() => onToggleCategoryExpansion(category.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary={category.name} />
|
||||
{hasChildren && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCategoryExpansion(category.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{childCategories.map((childCategory) => (
|
||||
<ListItem key={childCategory.id} disablePadding sx={{ pl: 3 }}>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === childCategory.id}
|
||||
onClick={() => onFilterChange('categoryId', childCategory.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={childCategory.name}
|
||||
sx={{ '& .MuiListItemText-primary': { fontSize: '0.9rem' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'background.paper'
|
||||
}}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Price Range
|
||||
</Typography>
|
||||
<Box sx={{ px: 1, mb: 2 }}>
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onChange={onPriceRangeChange}
|
||||
onChangeCommitted={onPriceRangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
valueLabelFormat={(value) => `$${value}`}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="caption">${priceRange[0]}</Typography>
|
||||
<Typography variant="caption">${priceRange[1]}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.isCustomizable === true}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onFilterChange('isCustomizable', e.target.checked ? true : null)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Customizable Only"
|
||||
sx={{ mb: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
93
ui/src/app/components/gallery/MobileFilterDrawer.tsx
Normal file
93
ui/src/app/components/gallery/MobileFilterDrawer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import CategorySidebar from './CategorySidebar';
|
||||
import { GalleryCategory, Filters } from '@/types';
|
||||
|
||||
interface MobileFilterDrawerProps {
|
||||
open: boolean;
|
||||
categories: GalleryCategory[];
|
||||
categoriesLoading: boolean;
|
||||
filters: Filters;
|
||||
expandedCategories: Set<string>;
|
||||
priceRange: number[];
|
||||
onClose: () => void;
|
||||
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||
onToggleCategoryExpansion: (categoryId: string) => void;
|
||||
onPriceRangeChange: (event: Event, newValue: number | number[]) => void;
|
||||
onPriceRangeCommitted: (event: Event | React.SyntheticEvent, newValue: number | number[]) => void;
|
||||
}
|
||||
|
||||
export default function MobileFilterDrawer({
|
||||
open,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
filters,
|
||||
expandedCategories,
|
||||
priceRange,
|
||||
onClose,
|
||||
onFilterChange,
|
||||
onToggleCategoryExpansion,
|
||||
onPriceRangeChange,
|
||||
onPriceRangeCommitted
|
||||
}: MobileFilterDrawerProps) {
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
Filters
|
||||
</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{categoriesLoading ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
px: 0
|
||||
}}>
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
filters={filters}
|
||||
expandedCategories={expandedCategories}
|
||||
priceRange={priceRange}
|
||||
onFilterChange={onFilterChange}
|
||||
onToggleCategoryExpansion={onToggleCategoryExpansion}
|
||||
onPriceRangeChange={onPriceRangeChange}
|
||||
onPriceRangeCommitted={onPriceRangeCommitted}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
191
ui/src/app/components/gallery/ProductCard.tsx
Normal file
191
ui/src/app/components/gallery/ProductCard.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Box,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { useUser } from '@auth0/nextjs-auth0';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GalleryProduct } from '@/types';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: GalleryProduct;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { user, isLoading } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const handleViewProduct = () => {
|
||||
router.push(`/products/${product.id}`);
|
||||
};
|
||||
|
||||
const handleBuild = () => {
|
||||
router.push(`/builder/${product.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: {
|
||||
xs: 'calc(50% - 6px)',
|
||||
sm: 'calc(50% - 8px)',
|
||||
md: '280px',
|
||||
lg: '320px'
|
||||
},
|
||||
flex: {
|
||||
xs: '0 0 calc(50% - 6px)',
|
||||
sm: '0 0 calc(50% - 8px)',
|
||||
md: '1 1 280px',
|
||||
lg: '1 1 320px'
|
||||
},
|
||||
maxWidth: {
|
||||
xs: 'calc(50% - 6px)',
|
||||
sm: 'calc(50% - 8px)',
|
||||
md: 'none',
|
||||
lg: 'none'
|
||||
},
|
||||
height: { xs: 240, sm: 300, lg: 340 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: { xs: 'none', sm: 'translateY(-2px)', md: 'translateY(-4px)' },
|
||||
boxShadow: { xs: 1, sm: 3, md: 4 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
objectFit: 'cover',
|
||||
height: { xs: 80, sm: 120, lg: 140 }
|
||||
}}
|
||||
/>
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: { xs: 0.75, sm: 1, lg: 1.25 },
|
||||
'&:last-child': { pb: { xs: 0.75, sm: 1, lg: 1.25 } }
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem', md: '0.9rem' },
|
||||
mb: { xs: 0.25, sm: 0.5 },
|
||||
lineHeight: 1.2
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
{product.category && (
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
mb: { xs: 0.25, sm: 0.5 },
|
||||
fontSize: { xs: '0.55rem', sm: '0.65rem' },
|
||||
height: { xs: 16, sm: 20 },
|
||||
'& .MuiChip-label': {
|
||||
px: { xs: 0.5, sm: 0.75 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
flexGrow: 1,
|
||||
mb: { xs: 0.5, sm: 0.75 },
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: { xs: 1, sm: 2, md: 2 },
|
||||
WebkitBoxOrient: 'vertical',
|
||||
minHeight: { xs: 16, sm: 24, md: 32 },
|
||||
fontSize: { xs: '0.65rem', sm: '0.7rem', md: '0.75rem' },
|
||||
lineHeight: 1.3
|
||||
}}>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: { xs: 0.5, sm: 0.75 }
|
||||
}}>
|
||||
<Typography variant="subtitle1" color="primary" sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '0.7rem', sm: '0.8rem', md: '0.85rem' }
|
||||
}}>
|
||||
From ${product.basePrice?.toFixed(2)}
|
||||
</Typography>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Custom"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: { xs: '0.55rem', sm: '0.65rem' },
|
||||
height: { xs: 16, sm: 20 },
|
||||
'& .MuiChip-label': {
|
||||
px: { xs: 0.5, sm: 0.75 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isLoading && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: { xs: 0.5, sm: 0.75 },
|
||||
flexDirection: { xs: 'column', sm: user ? 'row' : 'column' }
|
||||
}}>
|
||||
<Button
|
||||
variant={user ? "outlined" : "contained"}
|
||||
size="small"
|
||||
onClick={handleViewProduct}
|
||||
sx={{
|
||||
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||
py: { xs: 0.25, sm: 0.5 },
|
||||
flex: 1,
|
||||
minHeight: { xs: 24, sm: 32 }
|
||||
}}
|
||||
>
|
||||
{user ? "View" : "View Product"}
|
||||
</Button>
|
||||
{user && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleBuild}
|
||||
sx={{
|
||||
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||
py: { xs: 0.25, sm: 0.5 },
|
||||
flex: 1,
|
||||
minHeight: { xs: 24, sm: 32 }
|
||||
}}
|
||||
>
|
||||
Build
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
307
ui/src/app/components/gallery/SearchFilters.tsx
Normal file
307
ui/src/app/components/gallery/SearchFilters.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
SelectChangeEvent,
|
||||
Box,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Tune as TuneIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useState, ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { Filters } from '@/types';
|
||||
import { SORT_OPTIONS, PAGE_SIZE_OPTIONS } from '@/constants';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
filters: Filters;
|
||||
totalCount: number;
|
||||
productsCount: number;
|
||||
onFilterChange: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||
onOpenMobileDrawer: () => void;
|
||||
}
|
||||
|
||||
export default function SearchFilters({
|
||||
filters,
|
||||
totalCount,
|
||||
productsCount,
|
||||
onFilterChange,
|
||||
onOpenMobileDrawer
|
||||
}: SearchFiltersProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [searchInput, setSearchInput] = useState<string>('');
|
||||
|
||||
const handleSearch = (): void => {
|
||||
onFilterChange('searchTerm', searchInput);
|
||||
};
|
||||
|
||||
const handleSortChange = (value: string): void => {
|
||||
const [sortBy, sortDirection] = value.split('-');
|
||||
onFilterChange('sortBy', sortBy);
|
||||
onFilterChange('sortDirection', sortDirection);
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Paper elevation={1} sx={{
|
||||
p: { xs: 1, sm: 1.25 },
|
||||
mb: { xs: 1, sm: 1.5 }
|
||||
}}>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search products..."
|
||||
value={searchInput}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: '0.85rem',
|
||||
py: 1.25
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSearch}
|
||||
edge="end"
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 1
|
||||
}}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onOpenMobileDrawer}
|
||||
startIcon={<TuneIcon />}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
flexShrink: 0,
|
||||
minWidth: 'fit-content'
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<FormControl size="small" sx={{ flex: 1 }}>
|
||||
<InputLabel sx={{ fontSize: '0.8rem' }}>
|
||||
Sort
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||
label="Sort"
|
||||
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
fontSize: '0.8rem',
|
||||
py: 0.75
|
||||
}
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
sx={{ fontSize: '0.8rem' }}
|
||||
>
|
||||
{option.label.split(' ')[0]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ flex: 1 }}>
|
||||
<InputLabel sx={{ fontSize: '0.8rem' }}>
|
||||
Per
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={filters.pageSize}
|
||||
label="Per"
|
||||
onChange={(e: SelectChangeEvent<number>) =>
|
||||
onFilterChange('pageSize', e.target.value as number)
|
||||
}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
fontSize: '0.8rem',
|
||||
py: 0.75
|
||||
}
|
||||
}}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<MenuItem
|
||||
key={size}
|
||||
value={size}
|
||||
sx={{ fontSize: '0.8rem' }}
|
||||
>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
{productsCount} of {totalCount} products
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={1} sx={{
|
||||
p: { xs: 0.75, sm: 1, md: 1.25 },
|
||||
mb: { xs: 1, sm: 1.5 }
|
||||
}}>
|
||||
<Grid container spacing={{ xs: 0.75, sm: 1, md: 1.5 }} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search products..."
|
||||
value={searchInput}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||
py: { xs: 0.75, sm: 1 }
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSearch}
|
||||
edge="end"
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 6, sm: 3, md: 3 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}>
|
||||
Sort
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||
label="Sort"
|
||||
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||
py: { xs: 0.75, sm: 1 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}
|
||||
>
|
||||
{isSmall ? option.label.split(' ')[0] : option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 6, sm: 3, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}>
|
||||
{isSmall ? 'Per' : 'Per Page'}
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={filters.pageSize}
|
||||
label={isSmall ? 'Per' : 'Per Page'}
|
||||
onChange={(e: SelectChangeEvent<number>) =>
|
||||
onFilterChange('pageSize', e.target.value as number)
|
||||
}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' },
|
||||
py: { xs: 0.75, sm: 1 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<MenuItem
|
||||
key={size}
|
||||
value={size}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.85rem' } }}
|
||||
>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 2 }} sx={{
|
||||
textAlign: { xs: 'center', md: 'right' },
|
||||
mt: { xs: 0.5, md: 0 }
|
||||
}}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||
lineHeight: 1.2
|
||||
}}>
|
||||
{isSmall ? (
|
||||
`${productsCount}/${totalCount}`
|
||||
) : (
|
||||
`Showing ${productsCount} of ${totalCount}`
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
81
ui/src/app/components/orderbuilder/AddAddressDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Grid, TextField
|
||||
} from '@mui/material';
|
||||
|
||||
import { NewAddress } from '@/types'; // Or inline if needed
|
||||
|
||||
export default function AddAddressDialog({
|
||||
open,
|
||||
onClose,
|
||||
onAdd,
|
||||
newAddress,
|
||||
setNewAddress,
|
||||
}: {
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onAdd: () => void,
|
||||
newAddress: NewAddress,
|
||||
setNewAddress: (address: NewAddress) => void,
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Add New Address</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{[
|
||||
{ label: 'First Name', field: 'firstName' },
|
||||
{ label: 'Last Name', field: 'lastName' },
|
||||
{ label: 'Company (Optional)', field: 'company' },
|
||||
{ label: 'Address Line 1', field: 'addressLine1' },
|
||||
{ label: 'Address Line 2 (Optional)', field: 'addressLine2' },
|
||||
{ label: 'Apartment #', field: 'apartmentNumber' },
|
||||
{ label: 'Building #', field: 'buildingNumber' },
|
||||
{ label: 'Floor', field: 'floor' },
|
||||
{ label: 'City', field: 'city' },
|
||||
{ label: 'State', field: 'state' },
|
||||
{ label: 'Postal Code', field: 'postalCode' },
|
||||
{ label: 'Country', field: 'country' },
|
||||
{ label: 'Phone Number', field: 'phoneNumber' },
|
||||
].map(({ label, field }, index) => (
|
||||
<Grid size={{ xs:12 }} key={index}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={(newAddress as any)[field]}
|
||||
onChange={(e) => setNewAddress({ ...newAddress, [field]: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
<Grid size={{ xs:12 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Delivery Instructions (Optional)"
|
||||
value={newAddress.instructions}
|
||||
onChange={(e) => setNewAddress({ ...newAddress, instructions: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
variant="contained"
|
||||
disabled={
|
||||
!newAddress.firstName ||
|
||||
!newAddress.lastName ||
|
||||
!newAddress.addressLine1 ||
|
||||
!newAddress.city
|
||||
}
|
||||
>
|
||||
Add Address
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
124
ui/src/app/components/orderbuilder/StepChooseQuantity.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, IconButton, TextField, Fade, Stack, Divider } from '@mui/material';
|
||||
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||
import { Variant } from '@/types';
|
||||
|
||||
export default function StepChooseQuantity({
|
||||
selectedVariant,
|
||||
quantity,
|
||||
handleQuantityChange,
|
||||
getTotalPrice,
|
||||
setQuantity,
|
||||
}: {
|
||||
selectedVariant: Variant | null,
|
||||
quantity: number,
|
||||
handleQuantityChange: (delta: number) => void,
|
||||
getTotalPrice: () => number,
|
||||
setQuantity: (val: number) => void,
|
||||
}) {
|
||||
if (!selectedVariant) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Choose Quantity
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={4}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems="flex-start">
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ flex: 1 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${selectedVariant.price.toFixed(2)} each
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2} alignItems="center" sx={{ minWidth: 200 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
Quantity
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<IconButton
|
||||
onClick={() => handleQuantityChange(-1)}
|
||||
disabled={quantity <= 1}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
'&:hover': { borderColor: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 1;
|
||||
if (val >= 1) setQuantity(val);
|
||||
}}
|
||||
inputProps={{
|
||||
style: { textAlign: 'center', fontSize: '1.2rem', fontWeight: 600 },
|
||||
min: 1
|
||||
}}
|
||||
sx={{
|
||||
width: 80,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: 'grey.300',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleQuantityChange(1)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
'&:hover': { borderColor: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Total Price:
|
||||
</Typography>
|
||||
<Typography variant="h3" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
465
ui/src/app/components/orderbuilder/StepCustomization.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
IconButton,
|
||||
Grid,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Fade,
|
||||
Alert,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload as UploadIcon,
|
||||
Delete as DeleteIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Save as SaveIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fabric: any;
|
||||
}
|
||||
}
|
||||
|
||||
export default function StepCustomization({
|
||||
images,
|
||||
setImages,
|
||||
finalImageUrl,
|
||||
setFinalImageUrl,
|
||||
customizationDescription,
|
||||
setCustomizationDescription,
|
||||
loading,
|
||||
setLoading
|
||||
}: {
|
||||
images: CustomizationImage[];
|
||||
setImages: (images: CustomizationImage[]) => void;
|
||||
finalImageUrl: string;
|
||||
setFinalImageUrl: (url: string) => void;
|
||||
customizationDescription: string;
|
||||
setCustomizationDescription: (desc: string) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [fabricLoaded, setFabricLoaded] = useState(false);
|
||||
const [canvasInitialized, setCanvasInitialized] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricCanvasRef = useRef<any>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFabric = async () => {
|
||||
if (typeof window !== 'undefined' && !window.fabric) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js';
|
||||
script.onload = () => {
|
||||
console.log('Fabric.js loaded');
|
||||
setFabricLoaded(true);
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load Fabric.js');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else if (window.fabric) {
|
||||
console.log('Fabric.js already available');
|
||||
setFabricLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadFabric();
|
||||
|
||||
return () => {
|
||||
if (fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize canvas when Fabric is loaded AND canvas ref is available
|
||||
useEffect(() => {
|
||||
if (fabricLoaded && canvasRef.current && !canvasInitialized) {
|
||||
initCanvas();
|
||||
}
|
||||
}, [fabricLoaded, canvasInitialized]);
|
||||
|
||||
const initCanvas = () => {
|
||||
if (canvasRef.current && window.fabric && !fabricCanvasRef.current) {
|
||||
console.log('Initializing canvas');
|
||||
try {
|
||||
fabricCanvasRef.current = new window.fabric.Canvas(canvasRef.current, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
backgroundColor: 'white'
|
||||
});
|
||||
|
||||
// Add some visual feedback that canvas is working
|
||||
fabricCanvasRef.current.renderAll();
|
||||
setCanvasInitialized(true);
|
||||
console.log('Canvas initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize canvas:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImage = async (file: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('https://impr.ink/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
};
|
||||
|
||||
const handleFileSelect = async (files: FileList) => {
|
||||
if (images.length + files.length > 10) {
|
||||
alert('Maximum 10 images allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const newImages: CustomizationImage[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const url = await uploadImage(file);
|
||||
newImages.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
url,
|
||||
file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setImages([...images, ...newImages]);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addImageToCanvas = (imageUrl: string) => {
|
||||
console.log('Adding image to canvas:', imageUrl);
|
||||
console.log('Canvas available:', !!fabricCanvasRef.current);
|
||||
console.log('Fabric available:', !!window.fabric);
|
||||
|
||||
if (!fabricCanvasRef.current || !window.fabric) {
|
||||
console.error('Canvas or Fabric not available');
|
||||
return;
|
||||
}
|
||||
|
||||
window.fabric.Image.fromURL(imageUrl, (img: any) => {
|
||||
if (img) {
|
||||
console.log('Image loaded successfully');
|
||||
|
||||
// Scale the image to fit reasonably on canvas
|
||||
const maxWidth = 200;
|
||||
const maxHeight = 200;
|
||||
const scaleX = maxWidth / img.width;
|
||||
const scaleY = maxHeight / img.height;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
img.set({
|
||||
left: Math.random() * (800 - maxWidth),
|
||||
top: Math.random() * (600 - maxHeight),
|
||||
scaleX: scale,
|
||||
scaleY: scale
|
||||
});
|
||||
|
||||
fabricCanvasRef.current.add(img);
|
||||
fabricCanvasRef.current.setActiveObject(img);
|
||||
fabricCanvasRef.current.renderAll();
|
||||
console.log('Image added to canvas');
|
||||
} else {
|
||||
console.error('Failed to load image');
|
||||
}
|
||||
}, {
|
||||
crossOrigin: 'anonymous'
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
setImages(images.filter(img => img.id !== id));
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.clear();
|
||||
fabricCanvasRef.current.backgroundColor = 'white';
|
||||
fabricCanvasRef.current.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
const generateFinalImage = async () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const dataURL = fabricCanvasRef.current.toDataURL({
|
||||
format: 'png',
|
||||
quality: 0.9
|
||||
});
|
||||
|
||||
const response = await fetch(dataURL);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'customization.png', { type: 'image/png' });
|
||||
const url = await uploadImage(file);
|
||||
setFinalImageUrl(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate final image:', error);
|
||||
alert('Failed to generate final image. Please try again.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (!fabricLoaded) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={400}>
|
||||
<CircularProgress />
|
||||
<Typography ml={2}>Loading canvas editor...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Customize Your Product</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs:12, md:8 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">Design Canvas</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={clearCanvas}
|
||||
disabled={!canvasInitialized}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={generating ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||
onClick={generateFinalImage}
|
||||
disabled={generating || !canvasInitialized}
|
||||
>
|
||||
{generating ? 'Saving...' : 'Save Design'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px solid #ddd',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
backgroundColor: canvasInitialized ? 'transparent' : '#f5f5f5'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
{!canvasInitialized && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bgcolor="rgba(0,0,0,0.1)"
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
Initializing canvas...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
||||
Drag, resize, and rotate images on the canvas. Use the controls around selected images to manipulate them.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md:4 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Image Library</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={images.length >= 10 || uploading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Add Images ({images.length}/10)
|
||||
</Button>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '2px dashed',
|
||||
borderColor: 'grey.300',
|
||||
bgcolor: 'grey.50',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<UploadIcon sx={{ fontSize: 40, color: 'grey.500', mb: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Drop images here or click to browse
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box>
|
||||
{images.map((image, index) => (
|
||||
<Box key={image.id} mb={1}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: canvasInitialized ? 'pointer' : 'not-allowed',
|
||||
opacity: canvasInitialized ? 1 : 0.6,
|
||||
'&:hover': canvasInitialized ? { bgcolor: 'grey.50' } : {}
|
||||
}}
|
||||
onClick={() => canvasInitialized && addImageToCanvas(image.url)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`Image ${index + 1}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" flex={1}>
|
||||
Image {index + 1}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{canvasInitialized
|
||||
? 'Click on any image to add it to the canvas'
|
||||
: 'Canvas is initializing...'
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{finalImageUrl && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Saved Design</Typography>
|
||||
<img
|
||||
src={finalImageUrl}
|
||||
alt="Final design"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{uploading && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<CircularProgress size={16} />
|
||||
Uploading images...
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
handleFileSelect(e.target.files);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
241
ui/src/app/components/orderbuilder/StepDeliveryAddress.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box, Typography, FormControl, RadioGroup, FormControlLabel,
|
||||
Radio, Grid, Card, CardContent, Chip, Fade, Stack, Divider, Alert,
|
||||
Avatar, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LocationOn as LocationIcon,
|
||||
AddLocation as AddLocationIcon,
|
||||
Person as PersonIcon,
|
||||
Business as BusinessIcon,
|
||||
Phone as PhoneIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Address } from '@/types';
|
||||
|
||||
export default function StepDeliveryAddress({
|
||||
addresses,
|
||||
selectedAddress,
|
||||
setSelectedAddress,
|
||||
setShowAddressDialog,
|
||||
}: {
|
||||
addresses: Address[],
|
||||
selectedAddress: Address | null,
|
||||
setSelectedAddress: (addr: Address | null) => void,
|
||||
setShowAddressDialog: (open: boolean) => void,
|
||||
}) {
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
const parts = [
|
||||
address.addressLine1,
|
||||
address.addressLine2,
|
||||
`${address.city}, ${address.state} ${address.postalCode}`,
|
||||
address.country
|
||||
].filter(Boolean);
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const getInitials = (firstName: string, lastName: string) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Select Delivery Address
|
||||
</Typography>
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
No addresses found. Please add a delivery address to continue.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl component="fieldset" sx={{ width: '100%' }}>
|
||||
<RadioGroup
|
||||
value={selectedAddress?.id || ''}
|
||||
onChange={(e) => {
|
||||
const addr = addresses.find(a => a.id === e.target.value);
|
||||
setSelectedAddress(addr || null);
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
{addresses.map((address) => (
|
||||
<Grid key={address.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: 180,
|
||||
border: '2px solid',
|
||||
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'grey.200',
|
||||
bgcolor: selectedAddress?.id === address.id ? 'primary.50' : 'background.paper',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: selectedAddress?.id === address.id ? 'primary.main' : 'primary.light',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 2
|
||||
},
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
onClick={() => setSelectedAddress(address)}
|
||||
>
|
||||
<CardContent sx={{
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:last-child': { pb: 2 }
|
||||
}}>
|
||||
<FormControlLabel
|
||||
value={address.id}
|
||||
control={
|
||||
<Radio
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
p: 0
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5, pr: 4 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{getInitials(address.firstName, address.lastName)}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{address.firstName} {address.lastName}
|
||||
</Typography>
|
||||
{address.company && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{address.company}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{address.isDefault && (
|
||||
<Chip
|
||||
label="Default"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.6875rem',
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flex: 1 }}>
|
||||
<LocationIcon sx={{ fontSize: 16, color: 'text.secondary', mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.3,
|
||||
color: 'text.secondary',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{formatAddress(address)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mt: 'auto' }}
|
||||
>
|
||||
{address.phoneNumber ? (
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<PhoneIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{address.phoneNumber}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box />
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: 180,
|
||||
border: '2px dashed',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'primary.25',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.50',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 2
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowAddressDialog(true)}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<AddLocationIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" color="primary" sx={{ fontWeight: 600 }}>
|
||||
Add New Address
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8125rem' }}>
|
||||
Click to add delivery address
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
352
ui/src/app/components/orderbuilder/StepPayment.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { lightTheme } from '../theme/lightTheme';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Fade,
|
||||
Stack,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
useTheme,
|
||||
ThemeProvider,
|
||||
createTheme
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Payment as PaymentIcon,
|
||||
Receipt as ReceiptIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
useStripe,
|
||||
useElements,
|
||||
PaymentElement,
|
||||
AddressElement,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { Variant, Address } from '@/types';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export default function StepPayment({
|
||||
orderId,
|
||||
selectedVariant,
|
||||
quantity,
|
||||
getTotalPrice,
|
||||
selectedAddress,
|
||||
customizationImages = [],
|
||||
finalImageUrl = '',
|
||||
onSuccess
|
||||
}: {
|
||||
orderId: string;
|
||||
selectedVariant: Variant | null;
|
||||
quantity: number;
|
||||
getTotalPrice: () => number;
|
||||
selectedAddress: Address | null;
|
||||
customizationImages?: CustomizationImage[];
|
||||
finalImageUrl?: string;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const theme = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setMessage('');
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/payment-success`,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.type === 'card_error' || error.type === 'validation_error') {
|
||||
setMessage(error.message || 'An error occurred');
|
||||
} else {
|
||||
setMessage('An unexpected error occurred.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setMessage('Payment successful! 🎉');
|
||||
setIsSuccess(true);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Create Stripe appearance object with light theme forced for payment sections
|
||||
const stripeAppearance = {
|
||||
theme: 'stripe' as const, // Always use light theme
|
||||
variables: {
|
||||
colorPrimary: '#1976d2', // Use a consistent primary color
|
||||
colorBackground: '#ffffff', // Force white background
|
||||
colorText: '#212121', // Force dark text
|
||||
colorDanger: '#d32f2f', // Red for errors
|
||||
fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
rules: {
|
||||
'.Input': {
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
color: '#212121',
|
||||
fontSize: '14px',
|
||||
padding: '12px',
|
||||
},
|
||||
'.Input:focus': {
|
||||
borderColor: '#1976d2',
|
||||
boxShadow: '0 0 0 1px #1976d2',
|
||||
},
|
||||
'.Label': {
|
||||
color: '#424242',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
'.Tab': {
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
color: '#757575',
|
||||
},
|
||||
'.Tab:hover': {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
'.Tab--selected': {
|
||||
backgroundColor: '#1976d2',
|
||||
color: '#ffffff',
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const paymentElementOptions = {
|
||||
layout: 'tabs' as const,
|
||||
appearance: stripeAppearance,
|
||||
};
|
||||
|
||||
const addressElementOptions = {
|
||||
mode: 'billing' as const,
|
||||
// Remove allowedCountries to accept all countries
|
||||
fields: {
|
||||
phone: 'always' as const,
|
||||
},
|
||||
validation: {
|
||||
phone: {
|
||||
required: 'never' as const,
|
||||
},
|
||||
},
|
||||
appearance: stripeAppearance,
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, textAlign: 'center' }}>
|
||||
<Stack spacing={4} alignItems="center" sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'success.main',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(76, 175, 80, 0.3))'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'success.main',
|
||||
fontSize: { xs: '1.75rem', md: '2.125rem' }
|
||||
}}
|
||||
>
|
||||
Payment Successful!
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: { xs: '1rem', md: '1.25rem' } }}
|
||||
>
|
||||
Thank you for your purchase!
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ width: '100%', bgcolor: 'success.50', border: '1px solid', borderColor: 'success.200' }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ReceiptIcon color="success" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Order Details
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body1">
|
||||
<strong>Order ID:</strong> {orderId}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You will receive a confirmation email shortly with your order details and tracking information.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariant || !selectedAddress) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Complete Your Payment
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color} × {quantity}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Total Amount:
|
||||
</Typography>
|
||||
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Billing Address - Always Light Theme */}
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 3 }}>
|
||||
<PaymentIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Billing Information
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<AddressElement options={addressElementOptions} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
|
||||
{/* Payment Information - Always Light Theme */}
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Paper sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Payment Information
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<PaymentElement options={paymentElementOptions} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</ThemeProvider>
|
||||
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!stripe || !elements || isLoading}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
height: 48,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
<span>Processing...</span>
|
||||
</Stack>
|
||||
) : (
|
||||
`Pay ${getTotalPrice().toFixed(2)}$`
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
|
||||
{message && (
|
||||
<Alert
|
||||
severity={isSuccess ? "success" : "error"}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
116
ui/src/app/components/orderbuilder/StepProductDetails.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Fade,
|
||||
Grid,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import { Product } from '@/types';
|
||||
|
||||
export default function StepProductDetails({ product }: { product: Product | null }) {
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, sm: 3, md: 4 } }}>
|
||||
<Grid container spacing={{ xs: 3, md: 4 }} alignItems="flex-start">
|
||||
<Grid size={{ xs: 12, md: 5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={product.imageUrl}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: { xs: 250, sm: 300, md: 350 },
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
boxShadow: 2,
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 4
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 7 }}>
|
||||
<Stack spacing={{ xs: 2, md: 3 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
color: 'text.primary',
|
||||
fontSize: { xs: '1.75rem', sm: '2.125rem', md: '2.5rem' }
|
||||
}}
|
||||
>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="medium"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.8rem', md: '0.875rem' }
|
||||
}}
|
||||
/>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Customizable"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="medium"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.8rem', md: '0.875rem' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="h2"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '2.5rem', sm: '3rem', md: '3.5rem' },
|
||||
lineHeight: 1.1
|
||||
}}
|
||||
>
|
||||
${product.basePrice.toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
fontSize: { xs: '1rem', md: '1.125rem' },
|
||||
maxWidth: { md: '90%' }
|
||||
}}
|
||||
>
|
||||
{product.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
160
ui/src/app/components/orderbuilder/StepReviewOrder.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Grid, Card, CardContent, Fade, Stack, Divider } from '@mui/material';
|
||||
import { Variant, Address } from '@/types';
|
||||
|
||||
interface CustomizationImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export default function StepReviewOrder({
|
||||
selectedVariant,
|
||||
quantity,
|
||||
getTotalPrice,
|
||||
selectedAddress,
|
||||
customizationImages = [],
|
||||
finalImageUrl = '',
|
||||
customizationDescription = ''
|
||||
}: {
|
||||
selectedVariant: Variant | null,
|
||||
quantity: number,
|
||||
getTotalPrice: () => number,
|
||||
selectedAddress: Address | null,
|
||||
customizationImages?: CustomizationImage[],
|
||||
finalImageUrl?: string,
|
||||
customizationDescription?: string
|
||||
}) {
|
||||
if (!selectedVariant || !selectedAddress) return null;
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Box sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 600 }}>
|
||||
Review Your Order
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Stack spacing={4}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedVariant.imageUrl}
|
||||
alt={selectedVariant.size}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant.product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedVariant.size} - {selectedVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Quantity: {quantity}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h5" color="primary" sx={{ fontWeight: 700 }}>
|
||||
${getTotalPrice().toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{customizationImages.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Customization Details
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{customizationImages.length} original image{customizationImages.length > 1 ? 's' : ''} uploaded
|
||||
</Typography>
|
||||
{customizationDescription && (
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
{customizationDescription}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
{finalImageUrl && (
|
||||
<Card sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Preview
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={finalImageUrl}
|
||||
alt="Final customization"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Delivery Address
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{selectedAddress.firstName} {selectedAddress.lastName}
|
||||
</Typography>
|
||||
{selectedAddress.company && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedAddress.company}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine1}
|
||||
</Typography>
|
||||
{selectedAddress.addressLine2 && (
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.addressLine2}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.city}, {selectedAddress.state} {selectedAddress.postalCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{selectedAddress.country}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
110
ui/src/app/components/orderbuilder/StepSelectVariant.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Grid, Card, Fade, Stack, Chip } from '@mui/material';
|
||||
import { Variant } from '@/types';
|
||||
|
||||
export default function StepSelectVariant({
|
||||
variants,
|
||||
selectedVariant,
|
||||
setSelectedVariant,
|
||||
}: {
|
||||
variants: Variant[],
|
||||
selectedVariant: Variant | null,
|
||||
setSelectedVariant: (variant: Variant) => void
|
||||
}) {
|
||||
return (
|
||||
<Fade in>
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Select Variant
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{variants.map((variant) => (
|
||||
<Grid key={variant.id} size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'transparent',
|
||||
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'background.paper',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: selectedVariant?.id === variant.id ? 'primary.main' : 'primary.light',
|
||||
bgcolor: selectedVariant?.id === variant.id ? 'primary.50' : 'primary.25',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 3
|
||||
},
|
||||
p: 2
|
||||
}}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Box
|
||||
component="img"
|
||||
src={variant.imageUrl}
|
||||
alt={`${variant.size} - ${variant.color}`}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
flexShrink: 0,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack spacing={1} sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
color: selectedVariant?.id === variant.id ? 'primary.main' : 'text.primary'
|
||||
}}
|
||||
>
|
||||
{variant.size} - {variant.color}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
>
|
||||
SKU: {variant.sku}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
${variant.price.toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={variant.stockQuantity > 0 ? `${variant.stockQuantity} in stock` : 'Out of stock'}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
15
ui/src/app/components/orderbuilder/StepperHeader.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Stepper, Step, StepLabel } from '@mui/material';
|
||||
|
||||
export default function StepperHeader({ activeStep, steps }: { activeStep: number, steps: string[] }) {
|
||||
return (
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
@@ -4,125 +4,36 @@ import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
IconButton,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Paper,
|
||||
Slider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
SelectChangeEvent
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import {useState, useEffect, useCallback, KeyboardEvent, ChangeEvent, JSX} from 'react';
|
||||
import {
|
||||
Search,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
Close as CloseIcon,
|
||||
Tune as TuneIcon
|
||||
} from '@mui/icons-material';
|
||||
import {useState, useEffect, useCallback} from 'react';
|
||||
import clientApi from "@/lib/clientApi";
|
||||
import useRoles from "@/app/components/hooks/useRoles";
|
||||
import ProductCard from '@/app/components/gallery/ProductCard';
|
||||
import CategorySidebar from '@/app/components/gallery/CategorySidebar';
|
||||
import SearchFilters from '@/app/components/gallery/SearchFilters';
|
||||
import MobileFilterDrawer from '@/app/components/gallery/MobileFilterDrawer';
|
||||
import { GalleryProduct, GalleryCategory, Filters, ProductsResponse, ApiParams } from '@/types';
|
||||
|
||||
interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
parentCategoryId?: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
imageUrl?: string;
|
||||
isCustomizable: boolean;
|
||||
category?: Category;
|
||||
}
|
||||
|
||||
interface ProductsResponse {
|
||||
items: Product[];
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
searchTerm: string;
|
||||
categoryId: string;
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
isActive: boolean;
|
||||
isCustomizable: boolean | null;
|
||||
sortBy: string;
|
||||
sortDirection: string;
|
||||
}
|
||||
|
||||
interface ApiParams {
|
||||
PageNumber: number;
|
||||
PageSize: number;
|
||||
IsActive: boolean;
|
||||
SortBy: string;
|
||||
SortDirection: string;
|
||||
SearchTerm?: string;
|
||||
CategoryId?: string;
|
||||
MinPrice?: number;
|
||||
MaxPrice?: number;
|
||||
IsCustomizable?: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'Name-ASC', label: 'Name (A-Z)' },
|
||||
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
||||
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
||||
{ value: 'Price-DESC', label: 'Price (High to Low)' },
|
||||
{ value: 'CreatedDate-DESC', label: 'Newest First' },
|
||||
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS: number[] = [12, 24, 48, 96];
|
||||
|
||||
export default function GalleryPage(): JSX.Element {
|
||||
export default function GalleryPage() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { isAdmin } = useRoles();
|
||||
|
||||
const heightOffset = isAdmin ? 192 : 128;
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [products, setProducts] = useState<GalleryProduct[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalPages, setTotalPages] = useState<number>(0);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [categories, setCategories] = useState<GalleryCategory[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState<boolean>(true);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -141,12 +52,11 @@ export default function GalleryPage(): JSX.Element {
|
||||
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
|
||||
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
|
||||
const [searchInput, setSearchInput] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await clientApi.get<Category[]>('/products/categories');
|
||||
const response = await clientApi.get<GalleryCategory[]>('/products/categories');
|
||||
setCategories(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
@@ -200,10 +110,6 @@ export default function GalleryPage(): JSX.Element {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = (): void => {
|
||||
handleFilterChange('searchTerm', searchInput);
|
||||
};
|
||||
|
||||
const handlePriceRangeChange = (event: Event, newValue: number | number[]): void => {
|
||||
setPriceRange(newValue as number[]);
|
||||
};
|
||||
@@ -214,12 +120,6 @@ export default function GalleryPage(): JSX.Element {
|
||||
handleFilterChange('maxPrice', range[1]);
|
||||
};
|
||||
|
||||
const handleSortChange = (value: string): void => {
|
||||
const [sortBy, sortDirection] = value.split('-');
|
||||
handleFilterChange('sortBy', sortBy);
|
||||
handleFilterChange('sortDirection', sortDirection);
|
||||
};
|
||||
|
||||
const toggleCategoryExpansion = (categoryId: string): void => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(categoryId)) {
|
||||
@@ -230,152 +130,14 @@ export default function GalleryPage(): JSX.Element {
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const getChildCategories = (parentId: string): Category[] => {
|
||||
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||
};
|
||||
|
||||
const getParentCategories = (): Category[] => {
|
||||
return categories.filter(cat => !cat.parentCategoryId);
|
||||
};
|
||||
|
||||
const CategorySidebar = (): JSX.Element => (
|
||||
<Box sx={{
|
||||
width: 300,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
p: 2,
|
||||
pb: 1,
|
||||
flexShrink: 0,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
px: 2,
|
||||
minHeight: 0
|
||||
}}>
|
||||
<List dense>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={!filters.categoryId}
|
||||
onClick={() => handleFilterChange('categoryId', '')}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary="All Products" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{getParentCategories().map((category) => {
|
||||
const childCategories = getChildCategories(category.id);
|
||||
const hasChildren = childCategories.length > 0;
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
|
||||
return (
|
||||
<Box key={category.id}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === category.id}
|
||||
onClick={() => handleFilterChange('categoryId', category.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary={category.name} />
|
||||
{hasChildren && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCategoryExpansion(category.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{childCategories.map((childCategory) => (
|
||||
<ListItem key={childCategory.id} disablePadding sx={{ pl: 3 }}>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === childCategory.id}
|
||||
onClick={() => handleFilterChange('categoryId', childCategory.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={childCategory.name}
|
||||
sx={{ '& .MuiListItemText-primary': { fontSize: '0.9rem' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'background.paper'
|
||||
<Container maxWidth="xl" sx={{
|
||||
py: { xs: 0.75, sm: 1.5, md: 3 },
|
||||
px: { xs: 1, sm: 2 }
|
||||
}}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Price Range
|
||||
</Typography>
|
||||
<Box sx={{ px: 1, mb: 2 }}>
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onChange={handlePriceRangeChange}
|
||||
onChangeCommitted={handlePriceRangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={5}
|
||||
valueLabelFormat={(value) => `$${value}`}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="caption">${priceRange[0]}</Typography>
|
||||
<Typography variant="caption">${priceRange[1]}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.isCustomizable === true}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleFilterChange('isCustomizable', e.target.checked ? true : null)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Customizable Only"
|
||||
sx={{ mb: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: { xs: 0, md: 3 },
|
||||
gap: { xs: 0, md: 2 },
|
||||
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
|
||||
}}>
|
||||
{!isMobile && (
|
||||
@@ -390,11 +152,20 @@ export default function GalleryPage(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
) : (
|
||||
<CategorySidebar />
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
filters={filters}
|
||||
expandedCategories={expandedCategories}
|
||||
priceRange={priceRange}
|
||||
onFilterChange={handleFilterChange}
|
||||
onToggleCategoryExpansion={toggleCategoryExpansion}
|
||||
onPriceRangeChange={handlePriceRangeChange}
|
||||
onPriceRangeCommitted={handlePriceRangeCommitted}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
@@ -411,132 +182,58 @@ export default function GalleryPage(): JSX.Element {
|
||||
flex: 1,
|
||||
overflowY: { xs: 'visible', md: 'auto' },
|
||||
minHeight: 0,
|
||||
pr: { xs: 0, md: 1 }
|
||||
pr: { xs: 0, md: 0.5 }
|
||||
}}>
|
||||
<Box sx={{ mb: { xs: 2, sm: 2, md: 3 } }}>
|
||||
<Box sx={{ my: { xs: 1.5, sm: 2, md: 3 } }}>
|
||||
<Typography variant="h3" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '1.75rem', sm: '2rem', md: '2.5rem' },
|
||||
lineHeight: 1.2,
|
||||
mb: { xs: 0.5, sm: 0.75 }
|
||||
}}>
|
||||
Product Gallery
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{
|
||||
fontSize: { xs: '1rem', sm: '1rem' }
|
||||
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
|
||||
lineHeight: 1.3
|
||||
}}>
|
||||
Explore our complete collection of customizable products
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={1} sx={{
|
||||
p: { xs: 1, sm: 1, md: 1.5 },
|
||||
mb: { xs: 1, sm: 2 }
|
||||
}}>
|
||||
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||
{isMobile && (
|
||||
<Grid>
|
||||
<IconButton
|
||||
onClick={() => setMobileDrawerOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
size="small"
|
||||
>
|
||||
<TuneIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid size={{ xs:12, sm:6, md:4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search products..."
|
||||
value={searchInput}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||
size={isMobile ? "small" : "medium"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search fontSize={isMobile ? "small" : "medium"} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSearch}
|
||||
edge="end"
|
||||
>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
<SearchFilters
|
||||
filters={filters}
|
||||
totalCount={totalCount}
|
||||
productsCount={products.length}
|
||||
onFilterChange={handleFilterChange}
|
||||
onOpenMobileDrawer={() => setMobileDrawerOpen(true)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:6, sm:3, md:3 }}>
|
||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||
label="Sort By"
|
||||
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:6, sm:3, md:2 }}>
|
||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||
<InputLabel>Per Page</InputLabel>
|
||||
<Select
|
||||
value={filters.pageSize}
|
||||
label="Per Page"
|
||||
onChange={(e: SelectChangeEvent<number>) =>
|
||||
handleFilterChange('pageSize', e.target.value as number)
|
||||
}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<MenuItem key={size} value={size}>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md:3 }} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||
}}>
|
||||
Showing {products.length} of {totalCount} products
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
|
||||
<CircularProgress size={isMobile ? 40 : 60} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 3, md: 6 } }}>
|
||||
<CircularProgress size={isMobile ? 32 : 48} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: { xs: 2, md: 4 } }}>
|
||||
<Alert severity="error" sx={{
|
||||
mb: { xs: 1.5, md: 3 },
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||
}}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length === 0 && (
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom sx={{
|
||||
fontSize: { xs: '0.9rem', sm: '1rem' }
|
||||
}}>
|
||||
No products found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.85rem' }
|
||||
}}>
|
||||
Try adjusting your search criteria or filters
|
||||
</Typography>
|
||||
</Paper>
|
||||
@@ -546,121 +243,11 @@ export default function GalleryPage(): JSX.Element {
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: { xs: 1, sm: 1.5, md: 2 },
|
||||
mb: { xs: 2, md: 4 }
|
||||
gap: { xs: 0.75, sm: 1, md: 1.5 },
|
||||
mb: { xs: 1.5, md: 3 }
|
||||
}}>
|
||||
{products.map((product) => (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
width: {
|
||||
xs: 'calc(50% - 4px)',
|
||||
sm: 'calc(50% - 12px)',
|
||||
lg: 'calc(33.333% - 16px)'
|
||||
},
|
||||
maxWidth: { xs: 'none', sm: 350, lg: 370 },
|
||||
height: { xs: 300, sm: 380, lg: 420 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: { xs: 'none', sm: 'translateY(-4px)', md: 'translateY(-8px)' },
|
||||
boxShadow: { xs: 2, sm: 4, md: 6 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
objectFit: 'cover',
|
||||
height: { xs: 120, sm: 160, lg: 180 }
|
||||
}}
|
||||
/>
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: { xs: 1, sm: 1.5, lg: 2 },
|
||||
'&:last-child': { pb: { xs: 1, sm: 1.5, lg: 2 } }
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
|
||||
mb: { xs: 0.5, sm: 1 }
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
{product.category && (
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
mb: { xs: 0.5, sm: 1 },
|
||||
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||
height: { xs: 20, sm: 24 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
flexGrow: 1,
|
||||
mb: { xs: 1, sm: 1.5 },
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: { xs: 2, sm: 2, md: 3 },
|
||||
WebkitBoxOrient: 'vertical',
|
||||
minHeight: { xs: 28, sm: 32, md: 48 },
|
||||
fontSize: { xs: '0.75rem', sm: '0.8rem', md: '0.875rem' }
|
||||
}}>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: { xs: 1, sm: 1.5 }
|
||||
}}>
|
||||
<Typography variant="subtitle1" color="primary" sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '0.85rem', sm: '0.95rem', md: '1rem' }
|
||||
}}>
|
||||
From ${product.basePrice?.toFixed(2)}
|
||||
</Typography>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Custom"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||
height: { xs: 20, sm: 24 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size={isMobile ? "small" : "medium"}
|
||||
sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem' },
|
||||
py: { xs: 0.5, sm: 1, md: 1.5 }
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
@@ -669,14 +256,14 @@ export default function GalleryPage(): JSX.Element {
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pb: { xs: 2, md: 4 }
|
||||
pb: { xs: 1.5, md: 3 }
|
||||
}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={filters.pageNumber}
|
||||
onChange={(e, page) => handleFilterChange('pageNumber', page)}
|
||||
color="primary"
|
||||
size={isMobile ? 'small' : 'large'}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
showFirstButton={!isMobile}
|
||||
showLastButton={!isMobile}
|
||||
/>
|
||||
@@ -686,35 +273,19 @@ export default function GalleryPage(): JSX.Element {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
<MobileFilterDrawer
|
||||
open={mobileDrawerOpen}
|
||||
categories={categories}
|
||||
categoriesLoading={categoriesLoading}
|
||||
filters={filters}
|
||||
expandedCategories={expandedCategories}
|
||||
priceRange={priceRange}
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 280,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
Filters
|
||||
</Typography>
|
||||
<IconButton onClick={() => setMobileDrawerOpen(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{categoriesLoading ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : (
|
||||
<CategorySidebar />
|
||||
)}
|
||||
</Drawer>
|
||||
onFilterChange={handleFilterChange}
|
||||
onToggleCategoryExpansion={toggleCategoryExpansion}
|
||||
onPriceRangeChange={handlePriceRangeChange}
|
||||
onPriceRangeCommitted={handlePriceRangeCommitted}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
615
ui/src/app/orders/page.tsx
Normal file
615
ui/src/app/orders/page.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Chip,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Paper,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
Fade,
|
||||
Collapse,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ShoppingBag,
|
||||
LocalShipping,
|
||||
Close,
|
||||
ZoomIn,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
} from '@mui/icons-material';
|
||||
import clientApi from '@/lib/clientApi';
|
||||
|
||||
interface OrderStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShippingStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
parentCategoryId: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
isCustomizable: boolean;
|
||||
isActive: boolean;
|
||||
imageUrl: string;
|
||||
categoryId: string;
|
||||
category: Category;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
productId: string;
|
||||
size: string;
|
||||
color: string;
|
||||
price: number;
|
||||
imageUrl: string;
|
||||
sku: string;
|
||||
stockQuantity: number;
|
||||
isActive: boolean;
|
||||
product: Product;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface OrderAddress {
|
||||
id: string;
|
||||
orderId: string;
|
||||
addressType: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
apartmentNumber: string;
|
||||
buildingNumber: string;
|
||||
floor: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phoneNumber: string;
|
||||
instructions: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
userId: string;
|
||||
orderDate: string;
|
||||
amount: number;
|
||||
quantity: number;
|
||||
productId: string;
|
||||
productVariantId: string;
|
||||
orderStatusId: number;
|
||||
shippingStatusId: number;
|
||||
notes: string;
|
||||
merchantId: string;
|
||||
customizationImageUrl: string;
|
||||
originalImageUrls: string[];
|
||||
customizationDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
orderStatus: OrderStatus;
|
||||
shippingStatus: ShippingStatus;
|
||||
orderAddress: OrderAddress;
|
||||
product: Product;
|
||||
productVariant: ProductVariant;
|
||||
}
|
||||
|
||||
const getStatusColor = (statusName: string) => {
|
||||
const normalizedStatus = statusName.toLowerCase();
|
||||
if (normalizedStatus.includes('pending') || normalizedStatus.includes('processing')) {
|
||||
return 'warning';
|
||||
}
|
||||
if (normalizedStatus.includes('completed') || normalizedStatus.includes('delivered')) {
|
||||
return 'success';
|
||||
}
|
||||
if (normalizedStatus.includes('cancelled') || normalizedStatus.includes('failed')) {
|
||||
return 'error';
|
||||
}
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await clientApi.get('/orders/user/me?includeDetails=true');
|
||||
console.log("Data", response.data);
|
||||
setOrders(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || 'Failed to fetch orders');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, []);
|
||||
|
||||
const handleImageClick = (imageUrl: string) => {
|
||||
setSelectedImage(imageUrl);
|
||||
};
|
||||
|
||||
const handleCloseImage = () => {
|
||||
setSelectedImage(null);
|
||||
};
|
||||
|
||||
const toggleOrderExpansion = (orderId: string) => {
|
||||
const newExpanded = new Set(expandedOrders);
|
||||
if (newExpanded.has(orderId)) {
|
||||
newExpanded.delete(orderId);
|
||||
} else {
|
||||
newExpanded.add(orderId);
|
||||
}
|
||||
setExpandedOrders(newExpanded);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
My Orders
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Grid size={{ xs:12 }} key={index}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs:12, md:3 }}>
|
||||
<Skeleton variant="rectangular" height={200} />
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, md:9 }}>
|
||||
<Skeleton variant="text" height={32} />
|
||||
<Skeleton variant="text" height={24} />
|
||||
<Skeleton variant="text" height={24} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Skeleton variant="rectangular" height={32} width={100} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
My Orders
|
||||
</Typography>
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<ShoppingBag sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
No orders found
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Your orders will appear here once you make a purchase.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom sx={{ mb: 4 }}>
|
||||
My Orders
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{orders.map((order) => {
|
||||
const isExpanded = expandedOrders.has(order.id);
|
||||
|
||||
return (
|
||||
<Grid size={{ xs:12 }} key={order.id}>
|
||||
<Card elevation={2}>
|
||||
<CardContent
|
||||
sx={{ p: 2, cursor: 'pointer' }}
|
||||
onClick={() => toggleOrderExpansion(order.id)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Order #{order.id.slice(-8).toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(order.orderDate)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
icon={<ShoppingBag />}
|
||||
label={order.orderStatus.name}
|
||||
color={getStatusColor(order.orderStatus.name) as any}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
icon={<LocalShipping />}
|
||||
label={order.shippingStatus.name}
|
||||
color={getStatusColor(order.shippingStatus.name) as any}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
{formatCurrency(order.amount)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Qty: {order.quantity}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton size="small">
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box sx={{ px: 4, pb: 4, mt:2 }} >
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs:12, md:8 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Avatar
|
||||
src={order.product.imageUrl}
|
||||
alt={order.product.name}
|
||||
sx={{ width: 80, height: 80 }}
|
||||
variant="rounded"
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6">{order.product.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{order.product.description}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Variant:</strong> {order.productVariant.size} - {order.productVariant.color}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>SKU:</strong> {order.productVariant.sku}
|
||||
</Typography>
|
||||
{order.customizationDescription && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
<strong>Customization:</strong> {order.customizationDescription}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md:4 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Shipping Address
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{order.orderAddress.firstName} {order.orderAddress.lastName}
|
||||
<br />
|
||||
{order.orderAddress.company && (
|
||||
<>
|
||||
{order.orderAddress.company}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{order.orderAddress.addressLine1}
|
||||
<br />
|
||||
{order.orderAddress.addressLine2 && (
|
||||
<>
|
||||
{order.orderAddress.addressLine2}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{order.orderAddress.city}, {order.orderAddress.state} {order.orderAddress.postalCode}
|
||||
<br />
|
||||
{order.orderAddress.country}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{(order.productVariant.imageUrl || order.customizationImageUrl || order.originalImageUrls.length > 0) && (
|
||||
<Grid size={{ xs:12 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Images
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{order.productVariant.imageUrl && (
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
Product Variant
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 1,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => handleImageClick(order.productVariant.imageUrl)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={order.productVariant.imageUrl}
|
||||
alt="Product variant"
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
}}
|
||||
>
|
||||
<ZoomIn fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{order.customizationImageUrl && (
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
Customized
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 1,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => handleImageClick(order.customizationImageUrl)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={order.customizationImageUrl}
|
||||
alt="Customization"
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
}}
|
||||
>
|
||||
<ZoomIn fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{order.originalImageUrls.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" gutterBottom>
|
||||
Original Images ({order.originalImageUrls.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{order.originalImageUrls.slice(0, 3).map((imageUrl, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 1,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => handleImageClick(imageUrl)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageUrl}
|
||||
alt={`Original ${index + 1}`}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{index === 2 && order.originalImageUrls.length > 3 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.7)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
+{order.originalImageUrls.length - 3}
|
||||
</Box>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
}}
|
||||
>
|
||||
<ZoomIn sx={{ fontSize: 12 }} />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{order.notes && (
|
||||
<Grid size={{ xs:12 }}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Notes
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{order.notes}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
<Dialog
|
||||
open={!!selectedImage}
|
||||
onClose={handleCloseImage}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { bgcolor: 'transparent', boxShadow: 'none' }
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, position: 'relative' }}>
|
||||
<IconButton
|
||||
onClick={handleCloseImage}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
zIndex: 1,
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
{selectedImage && (
|
||||
<Fade in={!!selectedImage}>
|
||||
<Box
|
||||
component="img"
|
||||
src={selectedImage}
|
||||
alt="Full size preview"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: '90vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
12
ui/src/constants/index.ts
Normal file
12
ui/src/constants/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { SortOption } from '@/types';
|
||||
|
||||
export const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'Name-ASC', label: 'Name (A-Z)' },
|
||||
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
||||
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
||||
{ value: 'Price-DESC', label: 'Price (High to Low)' },
|
||||
{ value: 'CreatedDate-DESC', label: 'Newest First' },
|
||||
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
||||
];
|
||||
|
||||
export const PAGE_SIZE_OPTIONS: number[] = [12, 24, 48, 96];
|
||||
137
ui/src/types/index.ts
Normal file
137
ui/src/types/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {JSX} from 'react';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
parentCategoryId: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
isCustomizable: boolean;
|
||||
isActive: boolean;
|
||||
imageUrl: string;
|
||||
categoryId: string;
|
||||
category: Category;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface Variant {
|
||||
id: string;
|
||||
productId: string;
|
||||
size: string;
|
||||
color: string;
|
||||
price: number;
|
||||
imageUrl: string;
|
||||
sku: string;
|
||||
stockQuantity: number;
|
||||
isActive: boolean;
|
||||
product: Product;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
addressType: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
apartmentNumber: string;
|
||||
buildingNumber: string;
|
||||
floor: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phoneNumber: string;
|
||||
instructions: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface NewAddress {
|
||||
addressType: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
apartmentNumber: string;
|
||||
buildingNumber: string;
|
||||
floor: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phoneNumber: string;
|
||||
instructions: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GalleryCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
parentCategoryId?: string;
|
||||
}
|
||||
|
||||
export interface GalleryProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
imageUrl?: string;
|
||||
isCustomizable: boolean;
|
||||
category?: Category;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
items: Product[];
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
searchTerm: string;
|
||||
categoryId: string;
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
isActive: boolean;
|
||||
isCustomizable: boolean | null;
|
||||
sortBy: string;
|
||||
sortDirection: string;
|
||||
}
|
||||
|
||||
export interface ApiParams {
|
||||
PageNumber: number;
|
||||
PageSize: number;
|
||||
IsActive: boolean;
|
||||
SortBy: string;
|
||||
SortDirection: string;
|
||||
SearchTerm?: string;
|
||||
CategoryId?: string;
|
||||
MinPrice?: number;
|
||||
MaxPrice?: number;
|
||||
IsCustomizable?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user