Create Listing Wizard — Multi-Step Form with Photo Upload

📋 Table of Contents
  1. Create Listing Wizard
  2. Common Mistakes

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); },
    });
  }
}
Note: 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.
Tip: Photos are uploaded to the server individually as the user adds them (not batched at submission). This means if the user spends 5 minutes filling in the form and then submits, the photos are already on the CDN — submission is instant. The alternative (uploading all photos on submit) adds a long wait at the final step. The trade-off: if the user abandons the wizard, orphaned photo blobs remain on the server. A background cleanup job removes blobs that aren’t referenced by any listing after 24 hours.
Warning: The Angular CDK’s 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.

🧠 Test Yourself

The preview step’s previewListing is a computed signal derived from detailsForm and pricingForm. The user goes back to step 1 and changes the title. Does the preview update?