From a12125b2003f8deacfbf35cd61aada4e64578182 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 01:34:00 -0400 Subject: [PATCH] Add the Daily Picks, Trending and Just For You section content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal rails that make up the Explore feed, and the collage card that fronts Saved / Recently Viewed. `ExploreCollageCard` shows a single image when a collection is thin and a 2x2 grid once it has at least four listings, so a one-item Saved list doesn't render three empty tiles. `ExploreTrendingSection` puts the category picker in the section header itself — the header is the control — and shows a skeleton on first load only, so switching categories doesn't tear the rail down and rebuild it. Two things worth calling out for reviewers: - A trending card pushed the category list instead of the listing it displays. Fixed; it now pushes `.productDetails` like the Daily Picks cards do. - The upcoming-events section was a standalone shimmer with no data source and no caller. Left it out rather than shipping a placeholder that never resolves; it can come back with the events endpoint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W --- Resell.xcodeproj/project.pbxproj | 8 + Resell/Views/Explore/ExploreCollageCard.swift | 96 ++++++ Resell/Views/Explore/ExploreSections.swift | 294 ++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 Resell/Views/Explore/ExploreCollageCard.swift create mode 100644 Resell/Views/Explore/ExploreSections.swift diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 511ab8d..a7ae932 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -172,6 +172,8 @@ AD9A41632DCD0B58B830301D /* DailyPicksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0A9236FBD60E612257A18B /* DailyPicksView.swift */; }; AD1EEA9694D1ABD1CD4322D1 /* TrendingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE5FBCFC55ED36DEB120C4 /* TrendingView.swift */; }; ADA18E8891F5365FCF5D4A36 /* RecentlyViewedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD36843F11D34E925CA18F7B /* RecentlyViewedView.swift */; }; + AD352495F4C92AC5AF1AADF6 /* ExploreCollageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0062162947A49A54BA0E74 /* ExploreCollageCard.swift */; }; + ADC4811657FC86FA058DBC78 /* ExploreSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61E53786777FDFEDFFBC3F /* ExploreSections.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -338,6 +340,8 @@ AD0A9236FBD60E612257A18B /* DailyPicksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyPicksView.swift; sourceTree = ""; }; ADFE5FBCFC55ED36DEB120C4 /* TrendingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingView.swift; sourceTree = ""; }; AD36843F11D34E925CA18F7B /* RecentlyViewedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyViewedView.swift; sourceTree = ""; }; + AD0062162947A49A54BA0E74 /* ExploreCollageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreCollageCard.swift; sourceTree = ""; }; + AD61E53786777FDFEDFFBC3F /* ExploreSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSections.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -760,6 +764,8 @@ ADE4A10188B9356473A077AA /* Explore */ = { isa = PBXGroup; children = ( + AD61E53786777FDFEDFFBC3F /* ExploreSections.swift */, + AD0062162947A49A54BA0E74 /* ExploreCollageCard.swift */, AD36843F11D34E925CA18F7B /* RecentlyViewedView.swift */, ADFE5FBCFC55ED36DEB120C4 /* TrendingView.swift */, AD0A9236FBD60E612257A18B /* DailyPicksView.swift */, @@ -929,6 +935,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADC4811657FC86FA058DBC78 /* ExploreSections.swift in Sources */, + AD352495F4C92AC5AF1AADF6 /* ExploreCollageCard.swift in Sources */, ADA18E8891F5365FCF5D4A36 /* RecentlyViewedView.swift in Sources */, AD1EEA9694D1ABD1CD4322D1 /* TrendingView.swift in Sources */, AD9A41632DCD0B58B830301D /* DailyPicksView.swift in Sources */, diff --git a/Resell/Views/Explore/ExploreCollageCard.swift b/Resell/Views/Explore/ExploreCollageCard.swift new file mode 100644 index 0000000..392f035 --- /dev/null +++ b/Resell/Views/Explore/ExploreCollageCard.swift @@ -0,0 +1,96 @@ +// +// ExploreCollageCard.swift +// Resell +// +// Created by Andrew Gao on 9/3/26. +// + +import SwiftUI + +/// Collage preview showing either one image or a four-image grid. +struct ExploreCollageCard: View { + + let title: String + let subtitle: String? + let posts: [Post] + let action: () -> Void + + private let spacing: CGFloat = 2 + + @State private var loadedStates: [Bool] = Array(repeating: false, count: 4) + + private var previewPosts: [Post] { + Array(posts.prefix(posts.count >= 4 ? 4 : 1)) + } + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + GeometryReader { geo in + collage(size: geo.size) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + // Slightly wider than tall so cards sit a bit shorter than a square. + .aspectRatio(1.12, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + titleLabel + } + } + .buttonStyle(.plain) + .onChange(of: previewPosts.map(\.id)) { _ in + loadedStates = Array(repeating: false, count: 4) + } + } + + private var titleLabel: some View { + Text(subtitle.map { $0.isEmpty ? title : "\(title) • \($0)" } ?? title) + .font(Constants.Fonts.title2) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + } + + @ViewBuilder + private func collage(size: CGSize) -> some View { + let urls = previewPosts.map { URL(string: $0.images.first ?? "") } + let count = urls.count + let cellWidth = (size.width - spacing) / 2 + let cellHeight = (size.height - spacing) / 2 + + if count == 0 { + Constants.Colors.wash + } else if count == 1 { + imageCell(url: urls[0], index: 0) + .frame(width: size.width, height: size.height) + .clipped() + } else { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + imageCell(url: urls[0], index: 0) + .frame(width: cellWidth, height: cellHeight) + .clipped() + imageCell(url: urls[1], index: 1) + .frame(width: cellWidth, height: cellHeight) + .clipped() + } + HStack(spacing: spacing) { + imageCell(url: urls[2], index: 2) + .frame(width: cellWidth, height: cellHeight) + .clipped() + imageCell(url: urls[3], index: 3) + .frame(width: cellWidth, height: cellHeight) + .clipped() + } + } + } + } + + private func imageCell(url: URL?, index: Int) -> some View { + CachedImageView( + isImageLoaded: $loadedStates[index], + imageURL: url + ) + .clipped() + } +} diff --git a/Resell/Views/Explore/ExploreSections.swift b/Resell/Views/Explore/ExploreSections.swift new file mode 100644 index 0000000..b8b10ce --- /dev/null +++ b/Resell/Views/Explore/ExploreSections.swift @@ -0,0 +1,294 @@ +// +// ExploreSections.swift +// Resell +// +// Created by Andrew Gao on 9/3/26. +// + +import SwiftUI + +// MARK: - Daily Picks + +struct ExploreDailyPicksSection: View { + + @EnvironmentObject var router: Router + @ObservedObject private var viewModel = ExploreViewModel.shared + + private let cardWidth: CGFloat = 148 + private let imageHeight: CGFloat = 148 + + var body: some View { + if viewModel.isLoadingDailyPicks && viewModel.dailyPicks.isEmpty { + ExploreDailyPicksSkeleton() + } else if !viewModel.dailyPicks.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Daily Picks") + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + + Spacer() + + Button { + router.push(.dailyPicks) + } label: { + SeeMoreLabel() + } + .buttonStyle(.plain) + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(viewModel.dailyPicks) { post in + dailyPickCard(post) + } + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } + } + } + + private func dailyPickCard(_ post: Post) -> some View { + Button { + router.push(.productDetails(post)) + } label: { + VStack(alignment: .leading, spacing: 8) { + ExplorePostThumbnail(urlString: post.images.first) + .frame(width: cardWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Text(post.title) + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .frame(width: cardWidth, alignment: .leading) + + Text("$\(post.originalPrice)") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.secondaryGray) + .lineLimit(1) + } + .frame(width: cardWidth, alignment: .leading) + } + .buttonStyle(.plain) + } +} + +struct ExploreDailyPicksSkeleton: View { + + private let cardWidth: CGFloat = 148 + private let imageHeight: CGFloat = 148 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Daily Picks") + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + + Spacer() + + SeeMoreLabel() + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(0..<4, id: \.self) { _ in + pickCard + } + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } + } + + private var pickCard: some View { + VStack(alignment: .leading, spacing: 8) { + ShimmerView() + .frame(width: cardWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.25)) + .frame(width: cardWidth * 0.85, height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.18)) + .frame(width: cardWidth * 0.45, height: 10) + } + .frame(width: cardWidth, alignment: .leading) + } +} + +// MARK: - Trending + +struct ExploreTrendingSection: View { + + @EnvironmentObject var router: Router + @ObservedObject private var viewModel = ExploreViewModel.shared + + private let cardWidth: CGFloat = 148 + private let imageHeight: CGFloat = 148 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + categoryHeader + + if viewModel.isLoadingTrending && viewModel.trendingPosts.isEmpty { + loadingCards + } else if viewModel.trendingPosts.isEmpty { + Text("No posts found") + .font(Constants.Fonts.body2) + .foregroundStyle(Constants.Colors.secondaryGray) + .frame(maxWidth: .infinity) + .frame(height: 120) + .background(Constants.Colors.wash) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(viewModel.trendingPosts) { post in + trendingCard(post) + } + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } + } + } + + private var categoryHeader: some View { + HStack(spacing: 12) { + Menu { + ForEach(viewModel.trendingCategories, id: \.id) { category in + Button { + viewModel.selectTrendingCategory(category) + } label: { + if category.id == viewModel.selectedTrendingCategory.id { + Label(category.title, systemImage: "checkmark") + } else { + Text(category.title) + } + } + } + } label: { + HStack(spacing: 6) { + Text("Trending in \(viewModel.selectedTrendingCategory.title)") + .font(Constants.Fonts.h2) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .minimumScaleFactor(0.8) + .allowsTightening(true) + + Image(systemName: "chevron.down") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Constants.Colors.black) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + + Button { + router.push(.trending(viewModel.selectedTrendingCategory)) + } label: { + SeeMoreLabel() + } + .buttonStyle(.plain) + .fixedSize() + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + + private func trendingCard(_ post: Post) -> some View { + Button { + router.push(.productDetails(post)) + } label: { + VStack(alignment: .leading, spacing: 8) { + ExplorePostThumbnail(urlString: post.images.first) + .frame(width: cardWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Text(post.title) + .font(Constants.Fonts.title3) + .foregroundStyle(Constants.Colors.black) + .lineLimit(1) + .frame(width: cardWidth, alignment: .leading) + + HStack(spacing: 6) { + Text("$\(post.originalPrice)") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.secondaryGray) + + Spacer() + + Text("\(post.displaySaveCount) \(post.displaySaveCount == 1 ? "save" : "saves")") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.secondaryGray) + } + .frame(width: cardWidth) + } + .frame(width: cardWidth, alignment: .leading) + } + .buttonStyle(.plain) + } + + private var loadingCards: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(0..<4, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + ShimmerView() + .frame(width: cardWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.25)) + .frame(width: cardWidth * 0.85, height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.18)) + .frame(width: cardWidth * 0.55, height: 10) + } + } + } + .padding(.horizontal, Constants.Spacing.horizontalPadding) + } + } +} + +// MARK: - Shared helpers + +/// Outlined "See More" pill shared by every Explore section header. +private struct SeeMoreLabel: View { + var body: some View { + Text("See More") + .font(Constants.Fonts.title4) + .foregroundStyle(Constants.Colors.secondaryGray) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Constants.Colors.stroke, lineWidth: 1) + ) + } +} + +private struct ExplorePostThumbnail: View { + + let urlString: String? + @State private var isLoaded = false + + var body: some View { + CachedImageView( + isImageLoaded: $isLoaded, + imageURL: URL(string: urlString ?? "") + ) + .clipped() + } +} +