The multi-step listing wizard is the most complex form in the classified website — it combines MatStepper navigation, multiple reactive form groups, parallel photo uploads, drag-and-drop reordering, and a live preview. The key UX insight: users find long forms daunting. Breaking the same data into 4 focused steps (“What are you selling? → Price and location → Add photos → Check and publish”) makes the process feel manageable and reduces abandonment.
Create Listing Wizard
// ── features/seller/create-listing/create-listing-wizard.component.ts ─────
@Component({
selector: 'app-create-listing-wizard',
standalone: true,
imports: [MatStepperModule, ReactiveFormsModule, MatFormFieldModule,
MatInputModule, MatSelectModule, MatButtonModule,
ImageUploadComponent, ListingCardComponent, CdkDrag, CdkDropList],
template: `
<mat-stepper linear #stepper data-cy="listing-wizard">
<!-- Step 1: Category and details ──────────────────────────────────── -->
<mat-step [stepControl]="detailsForm" label="Listing details">
<form [formGroup]="detailsForm">
<mat-form-field>
<mat-label>Category</mat-label>
<mat-select formControlName="category" data-cy="category-select">
@for (cat of categories; track cat.value) {
<mat-option [value]="cat.value">{{ cat.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput formControlName="title"
placeholder="What are you selling?"
data-cy="title-input">
<mat-hint align="end">
{{ detailsForm.get('title')!.value.length }}/200
</mat-hint>
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput formControlName="description"
rows="5"
placeholder="Describe the condition, features, reason for selling..."
data-cy="description-input"></textarea>
</mat-form-field>
<div class="step-actions">
<button mat-raised-button color="primary"
matStepperNext
[disabled]="detailsForm.invalid"
data-cy="next-step-1">
Next: Price & Location
</button>
</div>
</form>
</mat-step>
<!-- Step 2: Price and location ────────────────────────────────────── -->
<mat-step [stepControl]="pricingForm" label="Price & location">
<form [formGroup]="pricingForm">
<div class="price-row">
<mat-form-field class="currency-field">
<mat-select formControlName="currency">
<mat-option value="GBP">£ GBP</mat-option>
<mat-option value="EUR">€ EUR</mat-option>
<mat-option value="USD">$ USD</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>Price</mat-label>
<input matInput type="number" min="0" formControlName="price"
data-cy="price-input">
</mat-form-field>
</div>
<mat-form-field>
<mat-label>City</mat-label>
<input matInput formControlName="city" data-cy="city-input">
</mat-form-field>
<div class="step-actions">
<button mat-button matStepperPrevious>Back</button>
<button mat-raised-button color="primary" matStepperNext
[disabled]="pricingForm.invalid">
Next: Photos
</button>
</div>
</form>
</mat-step>
<!-- Step 3: Photos ───────────────────────────────────────────────── -->
<mat-step label="Photos">
<!-- Drag-and-drop photo list ──────────────────────────────────── -->
<div cdkDropList (cdkDropListDropped)="reorderPhotos($event)"
class="photo-list">
@for (photo of uploadedPhotos(); track photo.url) {
<div class="photo-item" cdkDrag>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
<img [src]="photo.url" alt="Photo" width="80" height="60">
@if ($first) { <mat-chip>Cover photo</mat-chip> }
<button mat-icon-button (click)="removePhoto(photo)">
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
<!-- Add more photos ──────────────────────────────────────────── -->
@if (uploadedPhotos().length < 10) {
<app-image-upload
[maxSizeMb]="10"
(uploaded)="onPhotoUploaded($event)" />
}
<div class="step-actions">
<button mat-button matStepperPrevious>Back</button>
<button mat-raised-button color="primary" matStepperNext>
Preview
</button>
</div>
</mat-step>
<!-- Step 4: Preview and publish ─────────────────────────────────── -->
<mat-step label="Preview & publish">
<p class="hint">This is how your listing will appear to buyers:</p>
<app-listing-card [listing]="previewListing()" />
<div class="step-actions">
<button mat-button matStepperPrevious>Back</button>
<button mat-raised-button color="primary"
(click)="onSubmit()"
[disabled]="submitting()"
data-cy="publish-listing-btn">
@if (submitting()) { Publishing... } @else { Publish Listing }
</button>
</div>
</mat-step>
</mat-stepper>
`,
})
export class CreateListingWizardComponent {
private listingsApi = inject(ListingsApiService);
private router = inject(Router);
private fb = inject(FormBuilder);
uploadedPhotos = signal<{ url: string }[]>([]);
submitting = signal(false);
detailsForm = this.fb.nonNullable.group({
category: ['', Validators.required],
title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(200)]],
description: ['', [Validators.required, Validators.minLength(20)]],
});
pricingForm = this.fb.nonNullable.group({
price: [0, [Validators.required, Validators.min(1)]],
currency: ['GBP'],
city: ['', Validators.required],
postcode: [''],
});
// Derived preview listing for the preview step
previewListing = computed(() => ({
id: '',
title: this.detailsForm.getRawValue().title,
price: this.pricingForm.getRawValue().price,
currency: this.pricingForm.getRawValue().currency,
city: this.pricingForm.getRawValue().city,
category: this.detailsForm.getRawValue().category,
thumbnailUrl: this.uploadedPhotos()[0]?.url ?? null,
photoCount: this.uploadedPhotos().length,
publishedAt: new Date().toISOString(),
} as ListingSummaryDto));
onPhotoUploaded(url: string): void {
this.uploadedPhotos.update(photos => [...photos, { url }]);
}
reorderPhotos(event: CdkDragDrop<{ url: string }[]>): void {
const photos = [...this.uploadedPhotos()];
moveItemInArray(photos, event.previousIndex, event.currentIndex);
this.uploadedPhotos.set(photos);
}
removePhoto(photo: { url: string }): void {
this.uploadedPhotos.update(ps => ps.filter(p => p.url !== photo.url));
}
onSubmit(): void {
this.submitting.set(true);
const d = this.detailsForm.getRawValue();
const p = this.pricingForm.getRawValue();
this.listingsApi.create({
title: d.title,
description: d.description,
category: d.category,
price: p.price,
currency: p.currency,
city: p.city,
postcode: p.postcode,
photoUrls: this.uploadedPhotos().map(ph => ph.url),
}).subscribe({
next: id => this.router.navigate(['/my-listings'], {
queryParams: { created: id } }),
error: () => { this.submitting.set(false); },
});
}
}
MatStepper with linear mode enforces step-by-step completion — users cannot skip to step 3 without completing step 1 and 2. Each step has a [stepControl] binding to its FormGroup. The “Next” button (matStepperNext) only advances when the step’s FormGroup is valid. This provides granular, step-level validation feedback without overwhelming the user with all errors at once.cdkDropList reorder function (moveItemInArray) from @angular/cdk/drag-drop requires importing CdkDragDrop, CdkDrag, and CdkDropList separately. When calling moveItemInArray, pass a copy of the array (not the signal’s value directly) — mutating the original array doesn’t trigger signal updates. Always spread the array: const photos = [...this.uploadedPhotos()]; moveItemInArray(photos, prev, curr); this.uploadedPhotos.set(photos).Common Mistakes
Mistake 1 — Uploading all photos on submit (long wait at the final step)
❌ Wrong — 10 photos uploaded in sequence at submit; user waits 30+ seconds at the final step with no feedback.
✅ Correct — upload photos as they are added; each upload completes instantly from the user’s perspective at submit time.
Mistake 2 — Non-linear stepper (users skip required fields)
❌ Wrong — non-linear stepper; user skips pricing step; submits listing with no price; backend validation fails.
✅ Correct — linear stepper with [stepControl] FormGroup binding; cannot advance until the current step is valid.