diff --git a/Imprink.Integration.Tests/Imprink.Integration.Tests.csproj b/Imprink.Integration.Tests/Imprink.Integration.Tests.csproj
new file mode 100644
index 0000000..b2f8cd9
--- /dev/null
+++ b/Imprink.Integration.Tests/Imprink.Integration.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 07c5680..3f2ef07 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -36,6 +36,7 @@ services:
- ACCEPT_EULA=Y
- SEQ_CACHE_SYSTEMRAMTARGET=0.9
- BASE_URI=${SEQ_BASE_URI}
+ - SEQ_FIRSTRUN_NOAUTHENTICATION=true
networks:
- app-network
diff --git a/src/Imprink.Application/Commands/Categories/CreateCategoryHandler.cs b/src/Imprink.Application/Commands/Categories/CreateCategoryHandler.cs
index 0d088ca..310578a 100644
--- a/src/Imprink.Application/Commands/Categories/CreateCategoryHandler.cs
+++ b/src/Imprink.Application/Commands/Categories/CreateCategoryHandler.cs
@@ -33,6 +33,7 @@ public class CreateCategoryHandler(IUnitOfWork unitOfWork) : IRequestHandler(options =>
+ options.UseSqlite(_connection));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+
+ _serviceProvider = services.BuildServiceProvider();
+ _context = _serviceProvider.GetRequiredService();
+ _handler = _serviceProvider.GetRequiredService();
+
+ _context.Database.EnsureCreated();
+ }
+
+ private async Task CleanDatabase()
+ {
+ _context.Categories.RemoveRange(_context.Categories);
+ await _context.SaveChangesAsync();
+ }
+
+ [Fact]
+ public async Task Handle_ValidCreateCategoryCommand_PersistsToDatabase()
+ {
+ await CleanDatabase();
+
+ // Arrange
+ var command = new CreateCategoryCommand
+ {
+ Name = "Electronics",
+ Description = "Electronic devices and gadgets",
+ ImageUrl = "https://example.com/electronics.jpg",
+ SortOrder = 1,
+ IsActive = true,
+ ParentCategoryId = null
+ };
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal(command.Name, result.Name);
+ Assert.Equal(command.Description, result.Description);
+ Assert.Equal(command.ImageUrl, result.ImageUrl);
+ Assert.Equal(command.SortOrder, result.SortOrder);
+ Assert.Equal(command.IsActive, result.IsActive);
+ Assert.Equal(command.ParentCategoryId, result.ParentCategoryId);
+ Assert.NotNull(result.CreatedAt);
+ Assert.NotNull(result.ModifiedAt);
+
+ var savedCategory = await _context.Categories
+ .FirstOrDefaultAsync(c => c.Id == result.Id);
+
+ Assert.NotNull(savedCategory);
+ Assert.Equal(command.Name, savedCategory.Name);
+ Assert.Equal(command.Description, savedCategory.Description);
+ Assert.Equal(command.ImageUrl, savedCategory.ImageUrl);
+ Assert.Equal(command.SortOrder, savedCategory.SortOrder);
+ Assert.Equal(command.IsActive, savedCategory.IsActive);
+ Assert.Equal(command.ParentCategoryId, savedCategory.ParentCategoryId);
+ Assert.NotNull(savedCategory.CreatedAt);
+ Assert.NotNull(savedCategory.ModifiedAt);
+ }
+
+ [Fact]
+ public async Task Handle_ValidCreateCategoryCommandWithParent_PersistsWithCorrectParentRelationship()
+ {
+ await CleanDatabase();
+
+ // Arrange
+ var parentCategory = new Category
+ {
+ Id = Guid.NewGuid(),
+ Name = "Parent Category",
+ Description = "Parent category description",
+ SortOrder = 1,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ ModifiedAt = DateTime.UtcNow
+ };
+
+ _context.Categories.Add(parentCategory);
+ await _context.SaveChangesAsync();
+
+ var command = new CreateCategoryCommand
+ {
+ Name = "Child Electronics",
+ Description = "Electronic devices and gadgets under parent",
+ ImageUrl = "https://example.com/child-electronics.jpg",
+ SortOrder = 2,
+ IsActive = true,
+ ParentCategoryId = parentCategory.Id
+ };
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(parentCategory.Id, result.ParentCategoryId);
+
+ var savedCategory = await _context.Categories
+ .Include(c => c.ParentCategory)
+ .FirstOrDefaultAsync(c => c.Id == result.Id);
+
+ Assert.NotNull(savedCategory);
+ Assert.NotNull(savedCategory.ParentCategory);
+ Assert.Equal(parentCategory.Id, savedCategory.ParentCategory.Id);
+ Assert.Equal(parentCategory.Name, savedCategory.ParentCategory.Name);
+
+ var parentWithChildren = await _context.Categories
+ .Include(c => c.SubCategories)
+ .FirstOrDefaultAsync(c => c.Id == parentCategory.Id);
+
+ Assert.NotNull(parentWithChildren);
+ Assert.Contains(parentWithChildren.SubCategories, sc => sc.Id == result.Id);
+ }
+
+ [Fact]
+ public async Task Handle_CreateMultipleCategories_AllPersistCorrectly()
+ {
+ await CleanDatabase();
+
+ // Arrange
+ var commands = new[]
+ {
+ new CreateCategoryCommand
+ {
+ Name = "Category 1",
+ Description = "First category",
+ SortOrder = 1,
+ IsActive = true
+ },
+ new CreateCategoryCommand
+ {
+ Name = "Category 2",
+ Description = "Second category",
+ SortOrder = 2,
+ IsActive = false
+ }
+ };
+
+ // Act
+ var results = new List();
+ foreach (var command in commands)
+ {
+ var result = await _handler.Handle(command, CancellationToken.None);
+ results.Add(result);
+ }
+
+ // Assert
+ Assert.Equal(2, results.Count);
+
+ var categoriesInDb = await _context.Categories.ToListAsync();
+
+ Assert.Equal(2, categoriesInDb.Count);
+
+ foreach (var result in results)
+ {
+ var savedCategory = categoriesInDb.First(c => c.Id == result.Id);
+ Assert.Equal(result.Name, savedCategory.Name);
+ Assert.Equal(result.Description, savedCategory.Description);
+ Assert.Equal(result.IsActive, savedCategory.IsActive);
+ }
+ }
+
+ [Fact]
+ public async Task Handle_TransactionFailure_RollsBackCorrectly()
+ {
+ await CleanDatabase();
+
+ // Arrange
+ var command = new CreateCategoryCommand
+ {
+ Name = "Test Category",
+ Description = "Test description",
+ SortOrder = 1,
+ IsActive = true
+ };
+
+ var initialCount = await _context.Categories.CountAsync();
+
+ var handler = _serviceProvider.GetRequiredService();
+
+ var result = await handler.Handle(command, CancellationToken.None);
+
+ Assert.NotNull(result);
+
+ var finalCount = await _context.Categories.CountAsync();
+ Assert.Equal(initialCount + 1, finalCount);
+ }
+
+ public void Dispose()
+ {
+ GC.SuppressFinalize(this);
+ _context.Dispose();
+ _connection.Close();
+ _connection.Dispose();
+ _serviceProvider.GetService()?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/tests/Imprink.Application.Tests/Imprink.Application.Tests.csproj b/tests/Imprink.Application.Tests/Imprink.Application.Tests.csproj
index 643561b..12b9750 100644
--- a/tests/Imprink.Application.Tests/Imprink.Application.Tests.csproj
+++ b/tests/Imprink.Application.Tests/Imprink.Application.Tests.csproj
@@ -8,14 +8,18 @@
-
-
-
-
+
+
+
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -28,6 +32,7 @@
+
diff --git a/tests/Imprink.Application.Tests/UpdateCategoryHandlerUnitTest.cs b/tests/Imprink.Application.Tests/UpdateCategoryHandlerUnitTest.cs
new file mode 100644
index 0000000..3336fe8
--- /dev/null
+++ b/tests/Imprink.Application.Tests/UpdateCategoryHandlerUnitTest.cs
@@ -0,0 +1,244 @@
+using Imprink.Application.Commands.Categories;
+using Imprink.Application.Exceptions;
+using Imprink.Domain.Entities;
+using Imprink.Domain.Repositories;
+using Moq;
+
+namespace Imprink.Application.Tests;
+
+public class UpdateCategoryHandlerTests
+{
+ private readonly Mock _unitOfWorkMock;
+ private readonly Mock _categoryRepositoryMock;
+ private readonly UpdateCategoryHandler _handler;
+
+ public UpdateCategoryHandlerTests()
+ {
+ _unitOfWorkMock = new Mock();
+ _categoryRepositoryMock = new Mock();
+
+ _unitOfWorkMock.Setup(x => x.CategoryRepository)
+ .Returns(_categoryRepositoryMock.Object);
+
+ _handler = new UpdateCategoryHandler(_unitOfWorkMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ValidRequest_ShouldUpdateCategoryAndReturnDto()
+ {
+ var categoryId = Guid.NewGuid();
+ var parentCategoryId = Guid.NewGuid();
+ var createdAt = DateTime.UtcNow.AddDays(-10);
+ var modifiedAt = DateTime.UtcNow;
+
+ var existingCategory = new Category
+ {
+ Id = categoryId,
+ Name = "Old Name",
+ Description = "Old Description",
+ ImageUrl = "old-image.jpg",
+ SortOrder = 1,
+ IsActive = true,
+ ParentCategoryId = null,
+ CreatedAt = createdAt,
+ ModifiedAt = createdAt
+ };
+
+ var updatedCategory = new Category
+ {
+ Id = categoryId,
+ Name = "New Name",
+ Description = "New Description",
+ ImageUrl = "new-image.jpg",
+ SortOrder = 2,
+ IsActive = false,
+ ParentCategoryId = parentCategoryId,
+ CreatedAt = createdAt,
+ ModifiedAt = modifiedAt
+ };
+
+ var command = new UpdateCategoryCommand
+ {
+ Id = categoryId,
+ Name = "New Name",
+ Description = "New Description",
+ ImageUrl = "new-image.jpg",
+ SortOrder = 2,
+ IsActive = false,
+ ParentCategoryId = parentCategoryId
+ };
+
+ _categoryRepositoryMock
+ .Setup(x => x.GetByIdAsync(categoryId, It.IsAny()))
+ .ReturnsAsync(existingCategory);
+
+ _categoryRepositoryMock
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(updatedCategory);
+
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal(categoryId, result.Id);
+ Assert.Equal("New Name", result.Name);
+ Assert.Equal("New Description", result.Description);
+ Assert.Equal("new-image.jpg", result.ImageUrl);
+ Assert.Equal(2, result.SortOrder);
+ Assert.False(result.IsActive);
+ Assert.Equal(parentCategoryId, result.ParentCategoryId);
+ Assert.Equal(createdAt, result.CreatedAt);
+ Assert.Equal(modifiedAt, result.ModifiedAt);
+
+ _unitOfWorkMock.Verify(x => x.BeginTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.CommitTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.RollbackTransactionAsync(It.IsAny()), Times.Never);
+
+ _categoryRepositoryMock.Verify(x => x.GetByIdAsync(categoryId, It.IsAny()), Times.Once);
+ _categoryRepositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_CategoryNotFound_ShouldThrowNotFoundException()
+ {
+ // Arrange
+ var categoryId = Guid.NewGuid();
+ var command = new UpdateCategoryCommand
+ {
+ Id = categoryId,
+ Name = "Test Name",
+ Description = "Test Description",
+ SortOrder = 1,
+ IsActive = true
+ };
+
+ _categoryRepositoryMock
+ .Setup(x => x.GetByIdAsync(categoryId, It.IsAny()))
+ .ReturnsAsync((Category?)null);
+
+ var exception = await Assert.ThrowsAsync(
+ () => _handler.Handle(command, CancellationToken.None));
+
+ Assert.Equal($"Category with ID {categoryId} not found.", exception.Message);
+
+ _unitOfWorkMock.Verify(x => x.BeginTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.RollbackTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.CommitTransactionAsync(It.IsAny()), Times.Never);
+
+ _categoryRepositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_UpdateThrowsException_ShouldRollbackTransactionAndRethrow()
+ {
+ var categoryId = Guid.NewGuid();
+ var existingCategory = new Category
+ {
+ Id = categoryId,
+ Name = "Existing Name",
+ Description = "Existing Description",
+ SortOrder = 1,
+ IsActive = true
+ };
+
+ var command = new UpdateCategoryCommand
+ {
+ Id = categoryId,
+ Name = "New Name",
+ Description = "New Description",
+ SortOrder = 2,
+ IsActive = false
+ };
+
+ var expectedException = new InvalidOperationException("Database error");
+
+ _categoryRepositoryMock
+ .Setup(x => x.GetByIdAsync(categoryId, It.IsAny()))
+ .ReturnsAsync(existingCategory);
+
+ _categoryRepositoryMock
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(expectedException);
+
+ var thrownException = await Assert.ThrowsAsync(
+ () => _handler.Handle(command, CancellationToken.None));
+
+ Assert.Same(expectedException, thrownException);
+
+ _unitOfWorkMock.Verify(x => x.BeginTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.RollbackTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.CommitTransactionAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_CategoryWithNullableFields_ShouldHandleNullValues()
+ {
+ // Arrange
+ var categoryId = Guid.NewGuid();
+ var createdAt = DateTime.UtcNow.AddDays(-5);
+ var modifiedAt = DateTime.UtcNow;
+
+ var existingCategory = new Category
+ {
+ Id = categoryId,
+ Name = "Test Category",
+ Description = "Test Description",
+ ImageUrl = "test-image.jpg",
+ SortOrder = 1,
+ IsActive = true,
+ ParentCategoryId = Guid.NewGuid(),
+ CreatedAt = createdAt,
+ ModifiedAt = createdAt
+ };
+
+ var updatedCategory = new Category
+ {
+ Id = categoryId,
+ Name = "Updated Category",
+ Description = "Updated Description",
+ ImageUrl = null,
+ SortOrder = 5,
+ IsActive = false,
+ ParentCategoryId = null,
+ CreatedAt = createdAt,
+ ModifiedAt = modifiedAt
+ };
+
+ var command = new UpdateCategoryCommand
+ {
+ Id = categoryId,
+ Name = "Updated Category",
+ Description = "Updated Description",
+ ImageUrl = null,
+ SortOrder = 5,
+ IsActive = false,
+ ParentCategoryId = null
+ };
+
+ _categoryRepositoryMock
+ .Setup(x => x.GetByIdAsync(categoryId, It.IsAny()))
+ .ReturnsAsync(existingCategory);
+
+ _categoryRepositoryMock
+ .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(updatedCategory);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(categoryId, result.Id);
+ Assert.Equal("Updated Category", result.Name);
+ Assert.Equal("Updated Description", result.Description);
+ Assert.Null(result.ImageUrl);
+ Assert.Equal(5, result.SortOrder);
+ Assert.False(result.IsActive);
+ Assert.Null(result.ParentCategoryId);
+ Assert.Equal(createdAt, result.CreatedAt);
+ Assert.Equal(modifiedAt, result.ModifiedAt);
+
+ _unitOfWorkMock.Verify(x => x.BeginTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.CommitTransactionAsync(It.IsAny()), Times.Once);
+ _unitOfWorkMock.Verify(x => x.RollbackTransactionAsync(It.IsAny()), Times.Never);
+ }
+}
\ No newline at end of file
diff --git a/webui/package-lock.json b/webui/package-lock.json
index 2270b2a..5956179 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -21,13 +21,15 @@
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"axios": "^1.9.0",
+ "formik": "^2.4.6",
"i18next": "^25.2.1",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"next-i18next": "^15.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-i18next": "^15.5.3"
+ "react-i18next": "^15.5.3",
+ "yup": "^1.6.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1954,6 +1956,15 @@
}
}
},
+ "node_modules/deepmerge": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+ "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2134,6 +2145,31 @@
"node": ">= 6"
}
},
+ "node_modules/formik": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz",
+ "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://opencollective.com/formik"
+ }
+ ],
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.1",
+ "deepmerge": "^2.1.1",
+ "hoist-non-react-statics": "^3.3.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "react-fast-compare": "^2.0.1",
+ "tiny-warning": "^1.0.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2634,6 +2670,18 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2991,6 +3039,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/property-expr": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
+ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
+ "license": "MIT"
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3018,6 +3072,12 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
+ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
+ "license": "MIT"
+ },
"node_modules/react-i18next": {
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
@@ -3287,12 +3347,42 @@
"node": ">=18"
}
},
+ "node_modules/tiny-case": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
+ "license": "MIT"
+ },
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+ "license": "MIT"
+ },
+ "node_modules/toposort": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
+ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
@@ -3329,6 +3419,18 @@
"engines": {
"node": ">= 6"
}
+ },
+ "node_modules/yup": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
+ "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
+ "license": "MIT",
+ "dependencies": {
+ "property-expr": "^2.0.5",
+ "tiny-case": "^1.0.3",
+ "toposort": "^2.0.2",
+ "type-fest": "^2.19.0"
+ }
}
}
}
diff --git a/webui/package.json b/webui/package.json
index d361ade..6159669 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -22,13 +22,15 @@
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"axios": "^1.9.0",
+ "formik": "^2.4.6",
"i18next": "^25.2.1",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"next-i18next": "^15.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-i18next": "^15.5.3"
+ "react-i18next": "^15.5.3",
+ "yup": "^1.6.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/webui/src/app/form/page.js b/webui/src/app/form/page.js
new file mode 100644
index 0000000..b87a321
--- /dev/null
+++ b/webui/src/app/form/page.js
@@ -0,0 +1,376 @@
+'use client';
+
+import React from 'react';
+import {
+ Box,
+ Card,
+ CardContent,
+ TextField,
+ Button,
+ Typography,
+ Grid,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ Select,
+ FormHelperText,
+ Chip,
+ Avatar,
+ Container,
+} from '@mui/material';
+import { useFormik } from 'formik';
+import * as yup from 'yup';
+import PersonIcon from '@mui/icons-material/Person';
+import EmailIcon from '@mui/icons-material/Email';
+import PhoneIcon from '@mui/icons-material/Phone';
+import WorkIcon from '@mui/icons-material/Work';
+import SendIcon from '@mui/icons-material/Send';
+
+const validationSchema = yup.object({
+ firstName: yup
+ .string('Enter your first name')
+ .min(2, 'First name should be at least 2 characters')
+ .required('First name is required'),
+ lastName: yup
+ .string('Enter your last name')
+ .min(2, 'Last name should be at least 2 characters')
+ .required('Last name is required'),
+ email: yup
+ .string('Enter your email')
+ .email('Enter a valid email')
+ .required('Email is required'),
+ phone: yup
+ .string('Enter your phone number')
+ .matches(/^[\+]?[1-9][\d]{0,15}$/, 'Enter a valid phone number')
+ .required('Phone number is required'),
+ age: yup
+ .number('Enter your age')
+ .min(18, 'Must be at least 18 years old')
+ .max(120, 'Must be less than 120 years old')
+ .required('Age is required'),
+ department: yup
+ .string('Select a department')
+ .required('Department is required'),
+ skills: yup
+ .array()
+ .min(1, 'Select at least one skill')
+ .required('Skills are required'),
+ bio: yup
+ .string('Enter your bio')
+ .min(10, 'Bio should be at least 10 characters')
+ .max(500, 'Bio should not exceed 500 characters')
+ .required('Bio is required'),
+});
+
+const departments = [
+ 'Engineering',
+ 'Marketing',
+ 'Sales',
+ 'HR',
+ 'Finance',
+ 'Operations',
+ 'Design',
+];
+
+const skillOptions = [
+ 'JavaScript',
+ 'React',
+ 'Node.js',
+ 'Python',
+ 'UI/UX Design',
+ 'Project Management',
+ 'Data Analysis',
+ 'Marketing',
+ 'Sales',
+ 'Communication',
+];
+
+export default function FormPage() {
+ const formik = useFormik({
+ initialValues: {
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ age: '',
+ department: '',
+ skills: [],
+ bio: '',
+ },
+ validationSchema: validationSchema,
+ onSubmit: (values, { setSubmitting, resetForm }) => {
+ setTimeout(() => {
+ console.log('Form submitted:', values);
+ alert('Form submitted successfully!');
+ setSubmitting(false);
+ resetForm();
+ }, 1000);
+ },
+ });
+
+ const handleSkillChange = (event) => {
+ const value = event.target.value;
+ formik.setFieldValue('skills', typeof value === 'string' ? value.split(',') : value);
+ };
+
+ return (
+
+ theme.palette.mode === 'dark'
+ ? 'linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%)'
+ : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ py: 4,
+ }}
+ >
+
+
+ theme.palette.mode === 'dark'
+ ? 'rgba(26, 26, 46, 0.95)'
+ : 'rgba(255, 255, 255, 0.95)',
+ backdropFilter: 'blur(10px)',
+ border: (theme) =>
+ theme.palette.mode === 'dark'
+ ? '1px solid rgba(99, 102, 241, 0.2)'
+ : '1px solid rgba(255, 255, 255, 0.3)',
+ }}
+ >
+
+
+
+
+
+
+ User Registration
+
+
+ Please fill out all required fields
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file