Part 5 — Deals, pricing, and a real board

작성자

카테고리:

← 피드로
DEV Community · Nerd Snipe · 2026-07-23 개발(SW)

Part 5 — Deals, pricing, and a real board

Level: Intermediate/Advanced · Time: ~35 minutes · Builds on: Part 4 — Tags, ratings, and richer rows · Series 2, Part 2 of 3

Your contacts are tagged and scored. Now give them money attached — a deal value, a quote with real line items, and a board to see all of it at once. By the end of this tutorial your Contacts app will, without exaggeration, be doing the job of a lightweight sales pipeline.

Keep that thought. We’ll come back to it in Part 6.

What we’re adding

  1. A Deal model attached to each contact, with a stage and a value.
  2. DFPriceView and DFPriceSummaryView — a real quote screen with subtotal, discount, tax, and total.
  3. DFQuantityStepper — editable line-item quantities on that quote.
  4. A deals board — DFGrid of DFEntityCards, grouped by stage, filtered with the DFChip row from Part 4.
  5. DFCarousel — a “hot leads” horizontal rail on the dashboard.

1. The Deal model

enum DealStage: String, CaseIterable, Identifiable, Hashable {
    case new, qualified, proposal, won
    var id: String { rawValue }
    var label: String {
        switch self {
        case .new:       "New"
        case .qualified: "Qualified"
        case .proposal:  "Proposal"
        case .won:       "Won"
        }
    }
}

struct LineItem: Identifiable, Hashable {
    let id = UUID()
    var name: String
    var unitPrice: Decimal
    var quantity: Int = 1
    var lineTotal: Decimal { unitPrice * Decimal(quantity) }
}

struct Deal: Identifiable, Hashable {
    let id = UUID()
    var contactID: Contact.ID
    var title: String
    var stage: DealStage = .new
    var lineItems: [LineItem]
    var value: Decimal { lineItems.reduce(0) { $0 + $1.lineTotal } }
}

Enter fullscreen mode Exit fullscreen mode

2. A quote screen — DFPriceView, DFQuantityStepper, DFPriceSummaryView

Three components, one screen, in the order a user actually reads a quote: line items with editable quantities, then a summary breakdown at the bottom.

struct QuoteView: View {
    @State var deal: Deal
    private let taxRate: Decimal = 0.08

    private var subtotal: Decimal { deal.lineItems.reduce(0) { $0 + $1.lineTotal } }
    private var tax: Decimal { subtotal * taxRate }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                DFCard {
                    VStack(alignment: .leading, spacing: 12) {
                        DFText("Line items", scale: .headline)
                        ForEach($deal.lineItems) { $item in
                            HStack {
                                VStack(alignment: .leading, spacing: 2) {
                                    DFText(item.name, scale: .body)
                                    DFPriceView(amount: item.unitPrice).dfPriceViewStyle(.compact)
                                }
                                Spacer()
                                DFQuantityStepper(value: $item.quantity, range: 1...20)
                                    .dfQuantityStepperStyle(.compact)
                            }
                        }
                    }
                }

                DFCard {
                    DFPriceSummaryView(lineItems: [
                        DFPriceLineItem(label: "Subtotal", amount: subtotal),
                        DFPriceLineItem(label: "Tax (8%)", amount: tax),
                        DFPriceLineItem(label: "Total", amount: subtotal + tax, emphasis: .total),
                    ])
                }
            }
            .padding()
        }
        .dfNavigationBar(title: deal.title) { }
    }
}

Enter fullscreen mode Exit fullscreen mode

Worth calling out:

  • DFPriceView(amount:) formats currency for you via Decimal.formatted(.currency(code:)) — locale-aware, no manual NumberFormatter. Pass compareAtAmount: and it renders a strikethrough automatically — handy for showing a discounted line item.
  • DFQuantityStepper is named that, not DFStepper, specifically to avoid colliding with SwiftUI’s own Stepper. .compact style keeps it tight enough to live inline in a row like this.
  • DFPriceSummaryView takes an array of DFPriceLineItems — emphasis: .total on the last one renders a divider above it and bumps it to a headline weight automatically. You don’t hand-roll the “is this the total row” styling logic.

Pro moment. Pro’s E-commerce vertical ships this exact quote/checkout pattern already wired to real order data, plus a revenue dashboard built on the same DFPriceView primitive. If quotes and invoices are core to your product, that’s a screen you don’t write twice.

3. A deals board with DFGrid

Group deals by stage, reuse the segment-filter chip row’s pattern for stage filtering, and lay the results out in a grid of DFEntityCards.

struct DealsBoardView: View {
    let deals: [Deal]
    @State private var selectedStage: DealStage?

    private var visible: [Deal] {
        guard let selectedStage else { return deals }
        return deals.filter { $0.stage == selectedStage }
    }

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 8) {
                DFChip(.selectable("All"), isSelected: selectedStage == nil)
                    .onTapGesture { selectedStage = nil }
                ForEach(DealStage.allCases) { stage in
                    DFChip(.selectable(stage.label), isSelected: selectedStage == stage)
                        .onTapGesture { selectedStage = stage }
                }
            }
            .padding()

            ScrollView {
                if visible.isEmpty {
                    DFEmptyState(icon: "briefcase", title: "No deals in this stage")
                        .padding(.top, 40)
                } else {
                    DFGrid(columns: .adaptive(minWidth: 200)) {
                        ForEach(visible) { deal in
                            DFEntityCard(
                                media: .systemImage("briefcase.fill"),
                                title: deal.title,
                                subtitle: DFPriceView.formattedAmount(deal.value, currencyCode: "USD"),
                                trailing: .badge(deal.stage.label)
                            )
                        }
                    }
                    .padding()
                }
            }
        }
        .dfNavigationBar(title: "Deals") { }
    }
}

Enter fullscreen mode Exit fullscreen mode

DFGrid(columns: .adaptive(minWidth: 200)) reflows automatically — three cards wide on an iPhone in landscape, six on a Mac window, one on a compact iPhone portrait — with zero size-class branching in this view.

4. A “hot leads” rail with DFCarousel

A dashboard strip showing your highest-scoring leads, horizontally scrollable, no paging ceremony required.

struct HotLeadsRail: View {
    let contacts: [Contact]   // pre-sorted by relationshipScore, highest first

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            DFText("Hot leads", scale: .headline).padding(.horizontal)
            DFCarousel {
                ForEach(contacts.prefix(8)) { contact in
                    DFEntityCard(
                        media: .avatarInitials(contact.initials),
                        title: contact.name,
                        subtitle: contact.role
                    )
                    .frame(width: 140)
                }
            }
            .padding(.horizontal)
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

DFCarousel is deliberately a plain themed ScrollView wrapper — no built-in page indicator or snap-to-page behavior. If you need true paging, compose your own TabView(.page); for a “scroll and glance” rail like this one, the simpler primitive is the right tool.

What you built

  • A Deal model with a stage and computed value.
  • A quote screen combining editable line items, a quantity stepper, and an automatic subtotal/tax/total breakdown.
  • A filterable deals board reflowing across screen sizes with zero manual breakpoints.
  • A horizontally scrolling “hot leads” rail on the dashboard.

Stop for a second: you now have contacts, segments, relationship scores, deals, a priced quote screen, and a filterable board. That’s not a demo anymore.

Coming in Part 6

The last tutorial in this series isn’t about a new component — it’s about when and how to tell the user “you don’t have to keep building this.” We’ll add a real, well-placed DFBanner, use DFEmptyState‘s new secondary action for an honest upgrade prompt, and put your hand-built board side-by-side with what Pro’s CRM vertical ships on day one.

Part 6 — Nudge, prompt, and know when to buy

What Pro already has here

Pipeline boards, quote screens, and lead-scoring dashboards are exactly what DesignFoundationPro’s CRM and E-commerce verticals ship — pre-wired, with CRMPreviewFixtures/DFEcommercePreviewFixtures standing in for your real data until you swap it. You just wrote a smaller version of both by hand. That’s useful to know before your next project, not just this one.

DesignFoundation Pro

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다