Compare commits

..

20 Commits

Author SHA1 Message Date
9f7519941a Improve bootstrap handling in SenderEditorPage
Added a `DxLoadingPanel` to display a loading indicator during the bootstrap process. Introduced the `IsBootstrapLoading` property to track bootstrap progress. Refactored `OnParametersSetAsync` to handle bootstrap tokens more effectively, including redirecting to `/sender` if no token is provided. Updated `TryApplyBootstrapAsync` to adjust navigation behavior when the bootstrap payload is `null`.

Enhanced `OnAfterRenderAsync` to process `_pendingBootstrapToken` more cleanly and removed redundant logic. Simplified navigation and improved code readability and maintainability.
2026-10-06 13:59:39 +02:00
66e58b4a1a Add envelope settings step to wizard
Introduced a new step (step 2) in the `CreateEnvelopeWizardPopup` for configuring envelope settings, including security, reminders, and expiration options.

- Added fields for certification type, final email options, 2FA, access codes, and reminder settings.
- Updated step titles, descriptions, and total steps from 4 to 5.
- Implemented validation logic for the new settings step.
- Added methods to handle changes in settings fields and apply defaults.
- Updated `CreateEnvelopeWizardResult` and `CreateEnvelopeBootstrapPayload` to include new fields.
- Modified `SenderEditorPage` and `SenderPage` to handle new settings.
- Styled the new step with updated CSS for grids, groups, and toggles.
- Refactored state reset logic to initialize new fields.
2026-10-06 12:59:55 +02:00
de9d8c6f37 Add document preview feature to wizard popup
Introduced a document preview feature in the `CreateEnvelopeWizardPopup` component. This includes:

- Injected `PdfRenderService` for rendering PDF pages.
- Replaced document metadata display with a preview card.
- Added `SenderPdfImageEditor` for interactive PDF previews.
- Implemented state management for preview pages and overlays.
- Added fallback logic to estimate PDF page count.
- Enhanced `ResetWizardState` to reset preview-related fields.
- Styled the preview card and wrap with new CSS classes.
- Added placeholder event handlers for editor interactions.
2026-10-06 11:48:03 +02:00
47f48ba12a Add PdfFileDropZone for interactive PDF uploads
Replaced static upload message in `CreateEnvelopeWizardPopup.razor` with a new `PdfFileDropZone` component to enhance user experience. The component supports drag-and-drop functionality, file selection, and dynamic states (e.g., disabled, drag-active).

Introduced `PdfFileDropZone.razor` with customizable parameters such as `Disabled`, `HasDocument`, `UploadText`, and `DragDropHintText`. Added event handling for drag-and-drop interactions and file selection.

Updated `sender-page.css` to include styles for the new component, ensuring seamless integration with the existing layout. Adjusted styles for hover effects, drag-active state, and disabled state.

These changes improve usability and make PDF uploads more intuitive.
2026-10-06 10:31:43 +02:00
60241c9c6d Refactor receiver suggestions filtering logic
Introduced `ReceiverSuggestionFilterHandler` to refine the
filtering of recent receiver suggestions based on name and
email. Added `SuggestionLimit` constant to replace hardcoded
limits and ensure consistency. Updated UI to use the new
`FilteredRecentSuggestions` property, which leverages the
`BuildFilteredRecentSuggestions` method for grouping,
ordering, and limiting suggestions. Adjusted
`OnDocumentSelectedAsync` to use the new constant. Added
necessary `@using` directive for the filter handler.
2026-10-06 09:26:43 +02:00
2a939b4cad Add filtering logic for receiver suggestions
Introduce `ReceiverFilterCriteria` to encapsulate filtering criteria for receivers, including normalization of `FullName` and `EmailAddress`. Add `ReceiverSuggestionFilterHandler` to filter `RecentReceiverSuggestionDto` objects based on the criteria. Includes methods for matching criteria and filtering suggestions.
2026-10-06 09:26:22 +02:00
e89d471c6f Refactor receiver input and improve UI/UX
Replaced receiver list with a tag-based `DxTagBox` for better flexibility and dynamic updates. Updated receiver management logic to synchronize `_receivers` and `_receiverTags`. Enhanced accessibility with ARIA attributes for the suggestion list.

Redesigned the "Add Receiver" button with custom styles, hover effects, and an inline SVG icon. Improved the suggestion list with horizontal scrolling and nowrap styling. Styled tags and added a remove button for each tag.

Increased recent receiver suggestions fetch limit to 20. Added validation to ensure default envelope type selection. Refactored empty state handling and cleaned up redundant code for better maintainability.
2026-10-05 21:58:51 +02:00
ece676fbcf Make Take optional and refactor query handling logic
Updated the `Take` property in `ReadRecentReceiverSuggestionsQuery` to be nullable, allowing for unlimited results when not specified. Refactored the `Handle` method in `ReadRecentReceiverSuggestionsQueryHandler` to conditionally apply the `Take` limit only when provided. Renamed the cancellation token parameter for consistency. Improved code clarity and flexibility by removing redundant operations and dynamically applying the `Take` logic.
2026-10-05 17:06:47 +02:00
fac4ec28a4 Add localization and UI updates for receiver actions
Added new localized strings for "Recently used recipients" and
"Skip" in German, English, and French resource files. Updated
the `CreateEnvelopeWizardPopup.razor` to enhance the "Add
Receiver" button with a primary render style and a new CSS
class. Introduced styles for the button in `sender-page.css`
to improve its appearance with a minimum width, bold font,
and box shadow.
2026-10-05 17:02:12 +02:00
a3b11ed63f Add recent receiver suggestions to wizard
Introduced a feature to display and apply recent receiver
suggestions in the `CreateEnvelopeWizardPopup` component.
Added a new section in step 2 to show recently used receivers,
with loading and state management logic. Updated the
`ReceiverService` to fetch suggestions from the backend API.

Enhanced the UI with new styles for the suggestions section
and improved localization support. Refactored state
management to handle loading, resetting, and applying
suggestions. Minor cleanup and adjustments to ensure proper
state transitions.
2026-10-05 16:53:57 +02:00
5627483589 Make RecentReceiverSuggestionDto immutable; refactor query
Updated RecentReceiverSuggestionDto to use `init` properties,
adding immutability and a new `LastUsedAt` field. Refactored
ReadRecentReceiverSuggestionsQueryHandler to simplify query
logic, remove `IMapper` dependency, and improve determinism
in results. Introduced tie-breaking for duplicate timestamps
and optimized ordering by `LastUsedAt` and `EmailAddress`.
Removed redundant `Include` and mapping steps for clarity.
2026-10-05 16:43:34 +02:00
bf23e88cf8 Add plan for recent receiver suggestions feature
Added a detailed plan document outlining the development of a "recent receiver suggestions" feature for step 3 of the sender wizard. The plan defines team working rules, objectives, functional requirements, and architecture direction across application, server, and client layers.

The implementation is divided into six phases, covering MediatR query creation, API endpoint addition, client integration, UI updates, regression testing, and technical debt tracking. Each phase includes black-box test suggestions to ensure quality and robustness. The plan emphasizes incremental development, user approval, and quality-focused processes.
2026-10-05 15:58:58 +02:00
9435561e78 Add recent receiver suggestions query and DTO
Introduce `RecentReceiverSuggestionDto` to represent recent receiver suggestions with `FullName` and `EmailAddress`.

Add `ReadRecentReceiverSuggestionsQuery` to retrieve recent receiver suggestions scoped by sender username and limited to 3-5 results.

Implement `ReadRecentReceiverSuggestionsQueryHandler` to process the query using Entity Framework Core and AutoMapper.

Update `MappingProfile` to map `EnvelopeReceiver` to `RecentReceiverSuggestionDto`.
2026-10-05 15:55:19 +02:00
db1e91a3f6 Set C# language version to latest in project file
Added the `<LangVersion>` property to `EnvelopeGenerator.Application.csproj` with the value `latest`. This ensures the project uses the most up-to-date C# language features supported by the compiler, enabling access to new enhancements and features as they become available.
2026-10-05 15:32:41 +02:00
0c664c0995 Improve UX, security, and maintainability
- Introduced a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent feedback.
- Standardized request-to-domain mapping with AutoMapper/profile conventions or reflection-based mappers, enforced at the Core library level.
- Removed dynamic SQL string composition and migrated to parameterized queries to address SQL injection vulnerabilities. Added CI guardrails to prevent reintroduction.
- Refined UX on `EnvelopeSenderPage.razor` to enforce deterministic row double-click behavior and align row action buttons/tooltips.
- Enabled SPA-style language switching for immediate UI culture updates without page reloads.
- Added sender-scoped caching for "recent receiver suggestions" with short TTL, invalidation hooks, and cross-sender isolation.
2026-10-05 15:20:58 +02:00
5db2f6f1e3 Refactor envelope type selection logic
Removed the placeholder option from the envelope type dropdown to simplify the UI. Introduced a new `GetDefaultEnvelopeTypeId()` method to centralize and enhance the logic for determining the default envelope type. This method prioritizes envelope types with titles containing "Handwritten Signature" if available, improving alignment with user preferences. Updated `_selectedEnvelopeTypeId` initialization to use the new method for better maintainability.
2026-10-05 15:04:09 +02:00
a535e20b0e Add "Next" button translations to localization files
Added a new resource entry for the "Next" button in the
localization files for German (de-DE), English (en-US), and
French (fr-FR). Translations include "Weiter" in German,
"Next" in English, and "Suivant" in French.
2026-10-05 15:01:15 +02:00
bb35a68b1f feat(sender-editor): consume bootstrap payload and auto-save draft
Accept bootstrapToken query parameter and consume one-time payload from sessionStorage-backed service.

Hydrate editor state with title, message, envelope type, receivers, and PDF document from bootstrap data.

Refactor draft save into reusable core flow and trigger non-navigating auto-save after bootstrap is applied.

Prevent stale token replay by clearing query token from URL and handling invalid tokens gracefully.
2026-10-05 14:54:06 +02:00
715add7f51 feat(sender): add create-envelope wizard with bootstrap handoff
Introduce a 4-step modal wizard on SenderPage for creating envelopes.

Validate required fields, support optional skip, receiver management, PDF upload, and keyboard shortcuts.

Persist wizard payload in sessionStorage via token-based bootstrap service and navigate to editor with bootstrapToken.

Register bootstrap storage service in both client and server DI containers and add dedicated sender wizard styling.
2026-10-05 14:53:48 +02:00
621e22701a Add markdown audit and update architecture docs
Added a "Markdown Audit Snapshot" section to document the status, gaps, and recommendations for markdown files in the repository. Included a "Documentation Audit TODO" checklist for tracking updates. Updated the "Active Architecture (Post-Migration)" section to reflect the current Blazor Auto (Server+WASM hybrid) setup. Identified and marked outdated or redundant markdown files for removal or archival. Highlighted the need for periodic synchronization of documentation with implementation progress.
2026-10-05 09:57:56 +02:00
21 changed files with 2357 additions and 9 deletions

View File

@@ -63,6 +63,36 @@ This section explains every markdown file in the repo: what it contains, why it
---
## Markdown Audit Snapshot (2026-10-05)
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
| File | Type | Status | Missing / Gap | Removal Recommendation |
|---|---|---|---|---|
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
### Documentation Audit TODO
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
---
## Active Architecture (Post-Migration)
**Frontend:** Blazor Auto (Server+WASM hybrid)
@@ -251,6 +281,7 @@ Manual testing workflow:
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
## Database

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
public class RecentReceiverSuggestionDto
{
public required string FullName { get; init; }
public required string EmailAddress { get; init; }
public required DateTime LastUsedAt { get; init; }
}

View File

@@ -4,6 +4,7 @@
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>

View File

@@ -16,5 +16,9 @@ public class MappingProfile : Profile
public MappingProfile()
{
CreateMap<Receiver, ReceiverDto>();
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
}
}

View File

@@ -0,0 +1,98 @@
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// <summary>
/// Reads recent receiver suggestions for envelope creation.
/// </summary>
/// <remarks>
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
/// </remarks>
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Sender username used for scoping suggestion results.
/// </summary>
internal string? Username { get; private init; }
/// <summary>
/// Desired number of results.
/// </summary>
public int? Take { get; init; } = null;
/// <summary>
/// Returns a new query instance scoped to the specified sender username.
/// </summary>
/// <param name="username">Sender username from authenticated claims.</param>
/// <returns>Scoped query instance.</returns>
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
{
return this with { Username = username };
}
}
/// <summary>
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
/// </summary>
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
/// </summary>
/// <param name="request">Suggestion query.</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>Ordered list of recent receiver suggestions.</returns>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
{
var query = repository.Query
.AsNoTracking()
.Where(er => er.Receiver != null);
if (request.Username is not null)
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
// 1) For each email, compute the latest usage timestamp.
var latestPerEmail = query
.GroupBy(er => er.Receiver!.EmailAddress)
.Select(g => new
{
EmailAddress = g.Key,
LastUsedAt = g.Max(er => er.AddedWhen),
});
// 2) Join back to rows that match the latest timestamp per email.
var latestRows =
from er in query
join latest in latestPerEmail
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
equals new { latest.EmailAddress, latest.LastUsedAt }
select new
{
EmailAddress = er.Receiver!.EmailAddress,
FullName = er.Name,
LastUsedAt = er.AddedWhen,
};
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
// pick a deterministic name via Max(FullName), then order by most recent usage.
var suggestions = latestRows
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
.Select(g => new RecentReceiverSuggestionDto
{
EmailAddress = g.Key.EmailAddress,
FullName = g.Max(x => x.FullName) ?? string.Empty,
LastUsedAt = g.Key.LastUsedAt,
})
.OrderByDescending(x => x.LastUsedAt)
.ThenBy(x => x.EmailAddress);
return request.Take is int take
? await suggestions.Take(take).ToListAsync(cancel)
: await suggestions.ToListAsync(cancel);
}
}

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Zurück</value>
</data>
<data name="Next" xml:space="preserve">
<value>Weiter</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Klicken Sie auf den</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Empfänger hinzufügen</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Zuletzt verwendete Empfänger</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Überspringen</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Empfänger löschen</value>
</data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Next" xml:space="preserve">
<value>Next</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Click the</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Add Receiver</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Recently used recipients</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Skip</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Delete Receiver</value>
</data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Retour</value>
</data>
<data name="Next" xml:space="preserve">
<value>Suivant</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Cliquez sur</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Ajouter un destinataire</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Destinataires recents</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Passer</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Supprimer le destinataire</value>
</data>

View File

@@ -0,0 +1,22 @@
namespace EnvelopeGenerator.Server.Client.Components.Sender;
public sealed record CreateEnvelopeWizardResult(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);

View File

@@ -0,0 +1,104 @@
@using Microsoft.AspNetCore.Components.Forms
<div class="sender-pdf-dropzone @DropZoneClass">
<div class="sender-pdf-dropzone__content">
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
{
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
}
</div>
<InputFile OnChange="OnFileSelectedAsync"
accept="@Accept"
disabled="@Disabled"
@ondragenter="OnDragEnter"
@ondragleave="OnDragLeave"
@ondragover="OnDragOver"
@ondrop="OnDrop"
class="sender-pdf-dropzone__input" />
</div>
@code {
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public bool HasDocument { get; set; }
[Parameter]
public string Accept { get; set; } = ".pdf";
[Parameter]
public string UploadText { get; set; } = "PDF hochladen";
[Parameter]
public string ReplaceText { get; set; } = "PDF ersetzen";
[Parameter]
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
[Parameter]
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
private int _dragDepth;
private bool _isDragActive;
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
private string DropZoneClass
{
get
{
if (Disabled)
return "sender-pdf-dropzone--disabled";
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
}
}
private void OnDragEnter(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth++;
_isDragActive = true;
}
private void OnDragLeave(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth = Math.Max(0, _dragDepth - 1);
if (_dragDepth == 0)
_isDragActive = false;
}
private void OnDragOver(DragEventArgs _)
{
if (Disabled)
return;
_isDragActive = true;
}
private void OnDrop(DragEventArgs _)
{
_dragDepth = 0;
_isDragActive = false;
}
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
{
_dragDepth = 0;
_isDragActive = false;
if (Disabled)
return;
if (FileSelected.HasDelegate)
await FileSelected.InvokeAsync(args);
}
}

View File

@@ -0,0 +1,25 @@
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public readonly record struct ReceiverFilterCriteria
{
public readonly string FullName;
public readonly string EmailAddress;
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
{
FullName = Normalize(fullName);
EmailAddress = Normalize(emailAddress);
}
public readonly bool Matches(ReceiverFilterCriteria criteria)
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
private static string Normalize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,26 @@
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
{
private readonly IReadOnlyList<Entry> _entries = suggestions
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
.ToList();
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
{
return _entries
.Where(x => x.Criteria.Matches(criteria))
.Select(x => x.Suggestion);
}
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
{
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
return Filter(criteria);
}
}

View File

@@ -21,6 +21,7 @@
@inject ReceiverService ReceiverService
@inject EnvelopeService EnvelopeService
@inject EnvelopeTypeService EnvelopeTypeService
@inject CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject AuthService AuthService
@inject IStringLocalizer<Resource> UiLocalizer
@inject IStringLocalizer<Model> ModelLocalizer
@@ -30,6 +31,11 @@
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
<DxLoadingPanel Visible="@IsBootstrapLoading"
IsContentBlocked="true"
ApplyBackgroundShading="true"
IndicatorAreaVisible="true"
Text='@L("SenderEditor_BootstrapLoadingHint", "Der Umschlag wird vorbereitet und die Daten werden uebernommen.")'>
<div class="envelope-viewer-layout">
@* ── Action Bar ── *@
@@ -425,6 +431,7 @@
}
</div>
</div>
</DxLoadingPanel>
<DxPopup @bind-Visible="_receiverPopupVisible"
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
@@ -589,6 +596,9 @@
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
[SupplyParameterFromQuery(Name = "bootstrapToken")]
public string? BootstrapToken { get; set; }
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt
@@ -609,6 +619,8 @@
int? _loadedEnvelopeId;
int? _editingEnvelopeId;
bool _isLoadingEnvelopeForEdit;
bool _bootstrapApplied;
string? _pendingBootstrapToken;
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
@@ -629,6 +641,7 @@
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
string _saveSuccessBody = string.Empty;
bool _saveNavigateToDashboard = false;
bool _saveDraftAfterBootstrap;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
@@ -663,6 +676,7 @@
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
bool IsBootstrapLoading => !IsEditMode && !_bootstrapApplied;
// ── Lifecycle ──
@@ -703,19 +717,115 @@
return;
}
if (IsEditMode)
if (IsEditMode || _bootstrapApplied)
return;
if (string.IsNullOrWhiteSpace(BootstrapToken))
{
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
_pendingBootstrapToken = BootstrapToken;
}
protected override Task OnAfterRenderAsync(bool firstRender)
async Task TryApplyBootstrapAsync(string token)
{
var payload = await CreateEnvelopeBootstrapStorageService.ConsumeAsync(token);
_bootstrapApplied = true;
if (payload is null)
{
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
_envelopeTitle = payload.Title ?? string.Empty;
_envelopeMessage = payload.Message ?? string.Empty;
_selectedEnvelopeTypeId = payload.EnvelopeTypeId;
_language = string.IsNullOrWhiteSpace(payload.Language) ? "de-DE" : payload.Language;
_useAccessCode = payload.UseAccessCode;
_tfaEnabled = payload.TFAEnabled;
_certificationType = payload.CertificationType;
_finalEmailToCreator = payload.FinalEmailToCreator;
_finalEmailToReceivers = payload.FinalEmailToReceivers;
_sendReminderEmails = payload.SendReminderEmails;
_firstReminderDays = payload.FirstReminderDays;
_reminderIntervalDays = payload.ReminderIntervalDays;
_expiresWhenDays = payload.ExpiresWhenDays;
_expiresWarningWhenDays = payload.ExpiresWarningWhenDays;
NormalizeSettings();
_receivers = payload.Receivers
.Select((receiver, index) => new ReceiverDraft(
Guid.NewGuid(),
receiver.FullName,
receiver.EmailAddress,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
_originalPdfBytes = payload.DocumentBytes;
_fileName = string.IsNullOrWhiteSpace(payload.DocumentFileName) ? "document.pdf" : payload.DocumentFileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
_saveDraftAfterBootstrap = true;
if (_pdfLoaded && _originalPdfBytes is not null)
{
try
{
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to render bootstrap PDF previews.");
_pagePreviews = [];
}
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
}
else
{
_pagePreviews = [];
_pageCount = 0;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isPageReady = true;
StateHasChanged();
await InvokeAsync(StateHasChanged);
}
if (!OperatingSystem.IsBrowser())
return;
if (!IsEditMode && !_bootstrapApplied && !string.IsNullOrWhiteSpace(_pendingBootstrapToken))
{
var token = _pendingBootstrapToken;
_pendingBootstrapToken = null;
await TryApplyBootstrapAsync(token);
await InvokeAsync(StateHasChanged);
return;
}
if (_saveDraftAfterBootstrap && !_isSaving)
{
_saveDraftAfterBootstrap = false;
await SaveDraftAndStayOnEditorAsync();
return;
}
return Task.CompletedTask;
}
async Task LoadEnvelopeForEditAsync(int envelopeId)
@@ -1108,12 +1218,17 @@
}
async Task SaveDraftAsync()
{
await SaveDraftCoreAsync(navigateToDashboard: true);
}
async Task<int?> SaveDraftCoreAsync(bool navigateToDashboard)
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return;
return null;
}
_isSaving = true;
@@ -1134,25 +1249,37 @@
_loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.Id;
if (previousEnvelopeId != result.Id)
if (navigateToDashboard)
{
NavigationManager.NavigateTo("/sender", forceLoad: false);
}
else if (previousEnvelopeId != result.Id)
{
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);
return result.Id;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
return null;
}
finally
{
_isSaving = false;
await InvokeAsync(StateHasChanged);
}
}
async Task SaveDraftAndStayOnEditorAsync()
{
Logger.LogInformation("[SenderEditor] Trigger bootstrap draft save.");
await SaveDraftCoreAsync(navigateToDashboard: false);
}
// ── Send envelope ──
async Task SendAsync()
{

View File

@@ -9,14 +9,17 @@
@using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Components.Sender
@using Microsoft.Extensions.Localization
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -414,6 +417,11 @@
</div>
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
VisibleChanged="OnCreateWizardVisibleChangedAsync"
EnvelopeTypes="@_envelopeTypes"
StartEditorRequested="StartEditorFromWizardAsync" />
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
@@ -567,6 +575,9 @@
private string _deleteReason = string.Empty;
private string? _deleteErrorMessage;
private bool _createWizardVisible = false;
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
@@ -596,6 +607,16 @@
_appLogo = logos.App;
_companyLogo = logos.Company;
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
_envelopeTypes = [];
}
await LoadEnvelopesAsync();
}
@@ -693,7 +714,40 @@
void CreateEnvelope()
{
Navigation.NavigateTo("/sender/editor");
_createWizardVisible = true;
}
Task OnCreateWizardVisibleChangedAsync(bool visible)
{
_createWizardVisible = visible;
return Task.CompletedTask;
}
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
{
var payload = new CreateEnvelopeBootstrapPayload(
Title: result.Title,
Message: result.Message,
EnvelopeTypeId: result.EnvelopeTypeId,
Language: result.Language,
UseAccessCode: result.UseAccessCode,
TFAEnabled: result.TFAEnabled,
CertificationType: result.CertificationType,
FinalEmailToCreator: result.FinalEmailToCreator,
FinalEmailToReceivers: result.FinalEmailToReceivers,
SendReminderEmails: result.SendReminderEmails,
FirstReminderDays: result.FirstReminderDays,
ReminderIntervalDays: result.ReminderIntervalDays,
ExpiresWhenDays: result.ExpiresWhenDays,
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
Receivers: result.Receivers
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
.ToList(),
DocumentBytes: result.DocumentBytes,
DocumentFileName: result.DocumentFileName);
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
}
void EditEnvelope()

View File

@@ -45,6 +45,7 @@ builder.Services.AddScoped<EnvelopeTypeService>();
builder.Services.AddScoped<LocalizationService>();
builder.Services.AddScoped<ReceiverService>();
builder.Services.AddScoped<PdfRenderService>();
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.Server.Client.Services;
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
{
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
{
var token = Guid.NewGuid().ToString("N");
var key = BuildStorageKey(token);
var json = JsonSerializer.Serialize(payload, JsonOptions);
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
return token;
}
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
{
if (string.IsNullOrWhiteSpace(token))
return null;
var key = BuildStorageKey(token);
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
}
catch
{
return null;
}
}
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
}
public sealed record CreateEnvelopeBootstrapPayload(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -26,4 +27,21 @@ public class ReceiverService(IHttpClientFactory httpClientFactory)
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
return emails ?? [];
}
/// <summary>
/// Fetches recent receiver suggestions for the authenticated sender.
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
/// </summary>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
return suggestions ?? [];
}
}

View File

@@ -112,6 +112,20 @@ public class EnvelopeReceiverController : ControllerBase
return name is null ? NotFound() : Ok(name);
}
/// <summary>
/// Returns recent receiver suggestions for the authenticated sender.
/// </summary>
/// <param name="query">Suggestion query options (take 3..5 is applied in handler).</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>List of recent distinct receiver suggestions.</returns>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet("recent-suggestions")]
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
{
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
return Ok(result);
}
/// <summary>
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
/// </summary>

View File

@@ -346,6 +346,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();

View File

@@ -1094,3 +1094,557 @@
[data-sf-theme="blazing-dark"] .sender-preview-error-msg {
color: rgba(255, 255, 255, 0.50);
}
.sender-create-wizard {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.25rem 0;
outline: none;
}
.sender-create-wizard-popup .dxbl-popup-content {
max-height: min(78vh, 760px);
overflow: auto;
}
.sender-create-wizard-popup .dxbl-popup-footer {
position: sticky;
bottom: 0;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(6px);
z-index: 2;
}
.sender-create-wizard__progress {
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 0.5rem;
}
.sender-create-wizard__progress-track {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: rgba(126, 34, 206, 0.18);
z-index: 0;
}
.sender-create-wizard__step {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
}
.sender-create-wizard__step-index {
width: 24px;
height: 24px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
font-weight: 700;
color: #64748b;
background: #f8fafc;
border: 1px solid rgba(126, 34, 206, 0.25);
}
.sender-create-wizard__step--active .sender-create-wizard__step-index {
color: #ffffff;
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
border-color: transparent;
box-shadow: 0 0 0 3px rgba(126, 34, 206, 0.15);
}
.sender-create-wizard__step--done .sender-create-wizard__step-index {
color: #ffffff;
background: #2a5298;
border-color: transparent;
}
.sender-create-wizard__meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sender-create-wizard__title {
font-size: 1rem;
font-weight: 700;
color: #1f2937;
}
.sender-create-wizard__description {
font-size: 0.875rem;
color: #64748b;
line-height: 1.4;
}
.sender-create-wizard__stage {
border: 1px solid rgba(126, 34, 206, 0.12);
border-radius: 10px;
background: rgba(126, 34, 206, 0.04);
padding: 0.85rem 0.95rem;
}
.sender-create-wizard__stage p {
margin: 0;
color: #334155;
font-size: 0.9rem;
}
.sender-create-wizard__footer {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
@media (max-width: 640px) {
.sender-create-wizard__footer {
flex-wrap: wrap;
}
.sender-create-wizard__footer .dxbl-btn {
flex: 1 1 calc(50% - 0.5rem);
}
}
.sender-create-wizard__validation {
border: 1px solid rgba(220, 38, 38, 0.24);
background: rgba(254, 226, 226, 0.9);
color: #991b1b;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
}
.sender-create-wizard__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.sender-create-wizard__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sender-create-wizard__field--full {
grid-column: 1 / -1;
}
.sender-create-wizard__settings-grid {
display: grid;
gap: 0.8rem;
}
.sender-create-wizard__settings-group {
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 10px;
background: rgba(248, 250, 252, 0.86);
padding: 0.8rem;
}
.sender-create-wizard__settings-title {
font-size: 0.8rem;
font-weight: 800;
color: #334155;
margin-bottom: 0.6rem;
}
.sender-create-wizard__settings-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1.1rem;
}
.sender-create-wizard__settings-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
font-weight: 600;
color: #334155;
}
.sender-create-wizard__label {
font-size: 0.82rem;
font-weight: 700;
color: #334155;
}
.sender-create-wizard__required {
color: #dc2626;
}
.sender-create-wizard__optional {
color: #64748b;
font-weight: 600;
}
.sender-create-wizard__select {
min-height: 38px;
border: 1px solid rgba(126, 34, 206, 0.25);
border-radius: 8px;
padding: 0.45rem 0.55rem;
background: #ffffff;
color: #1f2937;
}
.sender-create-wizard__receiver-actions {
display: flex;
justify-content: flex-end;
}
.sender-create-wizard__add-receiver-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 36px;
min-width: 220px;
font-weight: 700;
font-size: 0.84rem;
border: 1px solid rgba(14, 116, 144, 0.28);
border-radius: 8px;
background: rgba(236, 254, 255, 0.95);
color: #0f766e;
box-shadow: 0 6px 14px rgba(14, 116, 144, 0.15);
padding: 0.45rem 0.9rem;
}
.sender-create-wizard__add-receiver-btn:hover:not(:disabled) {
background: rgba(207, 250, 254, 1);
color: #0f766e;
}
.sender-create-wizard__add-receiver-btn:disabled {
opacity: 0.58;
cursor: not-allowed;
}
.sender-create-wizard__suggestions {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.sender-create-wizard__suggestions-title {
font-size: 0.8rem;
font-weight: 700;
color: #475569;
}
.sender-create-wizard__suggestion-list {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
gap: 0.45rem;
padding-bottom: 0.25rem;
}
.sender-create-wizard__suggestion-item {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
gap: 0.12rem;
border: 1px solid rgba(14, 116, 144, 0.22);
border-radius: 999px;
background: rgba(236, 254, 255, 0.85);
color: #0f172a;
padding: 0.35rem 0.7rem;
min-width: 0;
}
.sender-create-wizard__receiver-tagbox-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sender-create-wizard__receiver-tagbox {
width: 100%;
}
.sender-create-wizard__receiver-tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.45rem;
border-radius: 999px;
background: rgba(236, 254, 255, 0.95);
border: 1px solid rgba(14, 116, 144, 0.25);
}
.sender-create-wizard__receiver-tag-name {
font-weight: 700;
}
.sender-create-wizard__receiver-tag-email {
color: #64748b;
font-size: 0.78rem;
}
.sender-create-wizard__receiver-tag-remove {
border: 0;
background: transparent;
color: #0f766e;
font-size: 0.76rem;
font-weight: 700;
line-height: 1;
padding: 0;
width: 1rem;
height: 1rem;
cursor: pointer;
}
.sender-create-wizard__tagbox-empty {
height: 0;
padding: 0;
margin: 0;
}
.sender-create-wizard__suggestion-item:disabled {
opacity: 0.58;
}
.sender-create-wizard__suggestion-name {
font-size: 0.77rem;
font-weight: 700;
line-height: 1.1;
}
.sender-create-wizard__suggestion-email {
font-size: 0.72rem;
color: #155e75;
line-height: 1.1;
}
.sender-create-wizard__receiver-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sender-create-wizard__receiver-item {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid rgba(126, 34, 206, 0.18);
border-radius: 8px;
padding: 0.55rem 0.7rem;
background: rgba(248, 250, 252, 0.95);
gap: 0.75rem;
}
.sender-create-wizard__receiver-name {
font-size: 0.86rem;
font-weight: 700;
color: #1f2937;
}
.sender-create-wizard__receiver-email {
font-size: 0.79rem;
color: #64748b;
}
.sender-create-wizard__receiver-remove {
border: 1px solid rgba(220, 38, 38, 0.2);
border-radius: 6px;
background: rgba(220, 38, 38, 0.08);
color: #b91c1c;
font-size: 0.78rem;
font-weight: 700;
padding: 0.25rem 0.55rem;
}
.sender-create-wizard__upload-stage {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sender-create-wizard__upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(126, 34, 206, 0.28);
border-radius: 8px;
padding: 0.55rem 0.9rem;
background: rgba(126, 34, 206, 0.08);
color: #4c1d95;
font-size: 0.84rem;
font-weight: 700;
width: fit-content;
cursor: pointer;
}
.sender-create-wizard__upload-btn--disabled {
opacity: 0.55;
cursor: not-allowed;
}
.sender-create-wizard__hidden-input {
display: none;
}
.sender-pdf-dropzone {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
border: 1px dashed rgba(100, 116, 139, 0.5);
border-radius: 8px;
background: rgba(248, 250, 252, 0.9);
color: #334155;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.sender-pdf-dropzone:hover {
border-color: rgba(126, 34, 206, 0.45);
background: rgba(241, 245, 249, 0.95);
}
.sender-pdf-dropzone--drag-active {
border-color: rgba(16, 185, 129, 0.75);
background: linear-gradient(180deg, rgba(16, 185, 129, 0.14), rgba(16, 185, 129, 0.08));
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.16);
}
.sender-pdf-dropzone--disabled {
opacity: 0.55;
cursor: not-allowed;
}
.sender-pdf-dropzone__content {
text-align: center;
display: grid;
gap: 0.2rem;
padding: 0.75rem 0.9rem;
}
.sender-pdf-dropzone__title {
font-size: 0.86rem;
font-weight: 700;
}
.sender-pdf-dropzone__hint {
font-size: 0.78rem;
color: #64748b;
}
.sender-pdf-dropzone__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.sender-pdf-dropzone--disabled .sender-pdf-dropzone__input {
cursor: not-allowed;
}
.sender-create-wizard__document-meta {
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 8px;
background: rgba(16, 185, 129, 0.08);
padding: 0.55rem 0.7rem;
}
.sender-create-wizard__document-preview-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.sender-create-wizard__document-preview-wrap {
border: 1px solid rgba(148, 163, 184, 0.38);
border-radius: 10px;
background: rgba(248, 250, 252, 0.96);
height: 320px;
overflow: hidden;
}
.sender-create-wizard__document-preview-wrap .sender-image-editor-shell {
height: 100%;
}
.sender-create-wizard__document-preview-wrap .sender-image-editor {
min-height: 0;
}
.sender-create-wizard__document-name {
color: #064e3b;
font-size: 0.85rem;
font-weight: 700;
}
.sender-create-wizard__document-size {
color: #065f46;
font-size: 0.77rem;
}
.sender-create-wizard__empty-list {
border: 1px dashed rgba(100, 116, 139, 0.4);
border-radius: 8px;
padding: 0.6rem 0.75rem;
color: #475569;
font-size: 0.83rem;
background: rgba(248, 250, 252, 0.8);
}
@media (max-width: 760px) {
.sender-create-wizard-popup .dxbl-popup {
width: 96vw !important;
max-width: 96vw !important;
}
.sender-create-wizard__progress {
gap: 0.35rem;
}
.sender-create-wizard__title {
font-size: 0.93rem;
}
.sender-create-wizard__description {
font-size: 0.81rem;
}
.sender-create-wizard__form-grid {
grid-template-columns: 1fr;
}
.sender-create-wizard__receiver-item {
flex-direction: column;
align-items: flex-start;
}
.sender-create-wizard__receiver-remove {
width: 100%;
}
.sender-pdf-dropzone {
width: 100%;
}
.sender-create-wizard__upload-btn {
width: 100%;
}
}