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
- A
Dealmodel attached to each contact, with a stage and a value. -
DFPriceViewandDFPriceSummaryView— a real quote screen with subtotal, discount, tax, and total. -
DFQuantityStepper— editable line-item quantities on that quote. - A deals board —
DFGridofDFEntityCards, grouped by stage, filtered with theDFChiprow from Part 4. -
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 viaDecimal.formatted(.currency(code:))— locale-aware, no manualNumberFormatter. PasscompareAtAmount:and it renders a strikethrough automatically — handy for showing a discounted line item. -
DFQuantityStepperis named that, notDFStepper, specifically to avoid colliding with SwiftUI’s ownStepper..compactstyle keeps it tight enough to live inline in a row like this. -
DFPriceSummaryViewtakes an array ofDFPriceLineItems —emphasis: .totalon 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
DFPriceViewprimitive. 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
Dealmodel 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.
답글 남기기