@@ -5,6 +5,8 @@ struct CampaignDetailView: View {
55 let campaign : CampaignDTO
66 @Query private var watchlist : [ Campaign ]
77 @Environment ( \. modelContext) private var modelContext
8+ @State private var historyData : [ HistoryDataPoint ] = [ ]
9+ @State private var isLoadingHistory = false
810
911 private var isWatched : Bool {
1012 watchlist. contains { $0. pid == campaign. pid && $0. isWatched }
@@ -28,6 +30,9 @@ struct CampaignDetailView: View {
2830 }
2931 . navigationBarTitleDisplayMode ( . inline)
3032 . toolbar { toolbarItems }
33+ . task {
34+ await loadHistory ( )
35+ }
3136 }
3237
3338 private var heroImage : some View {
@@ -53,6 +58,16 @@ struct CampaignDetailView: View {
5358
5459 fundingStats
5560
61+ if let blurb = campaign. blurb, !blurb. isEmpty {
62+ ExpandableBlurbView ( blurb: blurb)
63+ }
64+
65+ if let velocity = campaign. velocity_24h,
66+ let delta = campaign. pledge_delta_24h,
67+ velocity > 0 || delta != 0 {
68+ momentumSection ( velocity: velocity, delta: delta)
69+ }
70+
5671 if let url = campaign. project_url, let link = URL ( string: url) {
5772 Link ( destination: link) {
5873 Label ( " Back this project " , systemImage: " arrow.up.right.square.fill " )
@@ -70,14 +85,30 @@ struct CampaignDetailView: View {
7085 private var fundingStats : some View {
7186 VStack ( spacing: 12 ) {
7287 fundingRing
73- HStack {
74- statBox ( label: " Goal " , value: formattedAmount ( campaign. goal_amount, currency: campaign. goal_currency) )
75- Divider ( )
76- statBox ( label: " Pledged " , value: formattedAmount ( campaign. pledged_amount, currency: campaign. goal_currency) )
77- Divider ( )
78- statBox ( label: " Days Left " , value: " \( daysLeft) " )
88+
89+ VStack ( spacing: 8 ) {
90+ HStack {
91+ statBox ( label: " Goal " ,
92+ value: formattedAmount ( campaign. goal_amount, currency: campaign. goal_currency) ,
93+ icon: " target " )
94+ Divider ( )
95+ statBox ( label: " Pledged " ,
96+ value: formattedAmount ( campaign. pledged_amount, currency: campaign. goal_currency) ,
97+ icon: " dollarsign.circle.fill " )
98+ }
99+ . frame ( height: 60 )
100+
101+ HStack {
102+ statBox ( label: " Backers " ,
103+ value: formatBackersLarge ( campaign. backers_count) ,
104+ icon: " person.2.fill " )
105+ Divider ( )
106+ statBox ( label: " Days Left " ,
107+ value: " \( daysLeft) " ,
108+ icon: " calendar " )
109+ }
110+ . frame ( height: 60 )
79111 }
80- . frame ( height: 60 )
81112 . padding ( )
82113 . background ( Color ( . systemGray6) )
83114 . clipShape ( RoundedRectangle ( cornerRadius: 12 ) )
@@ -100,14 +131,29 @@ struct CampaignDetailView: View {
100131 . padding ( . vertical, 8 )
101132 }
102133
103- private func statBox( label: String , value: String ) -> some View {
104- VStack ( spacing: 2 ) {
105- Text ( value) . font ( . subheadline) . fontWeight ( . semibold)
106- Text ( label) . font ( . caption2) . foregroundStyle ( . secondary)
134+ private func statBox( label: String , value: String , icon: String ? = nil ) -> some View {
135+ VStack ( spacing: 4 ) {
136+ if let icon = icon {
137+ Image ( systemName: icon)
138+ . font ( . system( size: 16 ) )
139+ . foregroundStyle ( . secondary)
140+ }
141+ Text ( value) . font ( . title3) . fontWeight ( . bold)
142+ Text ( label) . font ( . caption) . foregroundStyle ( . secondary)
107143 }
108144 . frame ( maxWidth: . infinity)
109145 }
110146
147+ private func formatBackersLarge( _ count: Int ? ) -> String {
148+ guard let count = count else { return " — " }
149+ if count >= 1_000_000 {
150+ return String ( format: " %.1fM " , Double ( count) / 1_000_000 )
151+ } else if count >= 1_000 {
152+ return String ( format: " %.1fK " , Double ( count) / 1_000 )
153+ }
154+ return " \( count) "
155+ }
156+
111157 private func formattedAmount( _ amount: Double ? , currency: String ? ) -> String {
112158 guard let amount else { return " — " }
113159 let sym = currency == " USD " ? " $ " : ( currency ?? " " )
@@ -156,4 +202,138 @@ struct CampaignDetailView: View {
156202 }
157203 try ? modelContext. save ( )
158204 }
205+
206+ private func momentumSection( velocity: Double , delta: Double ) -> some View {
207+ VStack ( alignment: . leading, spacing: 12 ) {
208+ HStack {
209+ Image ( systemName: " bolt.fill " )
210+ . foregroundStyle ( . orange)
211+ Text ( " 24-Hour Momentum " )
212+ . font ( . subheadline) . fontWeight ( . semibold)
213+ Spacer ( )
214+ momentumBadge ( velocity: velocity, delta: delta)
215+ }
216+
217+ if !historyData. isEmpty {
218+ SparklineView ( dataPoints: historyData)
219+ } else if isLoadingHistory {
220+ ProgressView ( )
221+ . frame ( height: 60 )
222+ . frame ( maxWidth: . infinity)
223+ }
224+
225+ HStack ( spacing: 16 ) {
226+ metricCard (
227+ icon: " dollarsign.circle.fill " ,
228+ label: " 24h Change " ,
229+ value: formatDelta ( delta) ,
230+ color: delta > 0 ? . green : . red
231+ )
232+ metricCard (
233+ icon: " percent " ,
234+ label: " Growth Rate " ,
235+ value: String ( format: " %.1f%% " , velocity) ,
236+ color: velocity > 0 ? . green : . red
237+ )
238+ }
239+ }
240+ . padding ( )
241+ . background ( Color ( . systemGray6) )
242+ . clipShape ( RoundedRectangle ( cornerRadius: 12 ) )
243+ }
244+
245+ private func momentumBadge( velocity: Double , delta: Double ) -> some View {
246+ HStack ( spacing: 4 ) {
247+ Image ( systemName: velocity > 0 ? " arrow.up.right " : " arrow.down.right " )
248+ . font ( . system( size: 10 ) )
249+ Text ( delta > 0 ? " + \( formatDelta ( delta) ) " : formatDelta ( delta) )
250+ . font ( . caption) . fontWeight ( . semibold)
251+ }
252+ . padding ( . horizontal, 8 ) . padding ( . vertical, 4 )
253+ . background ( ( velocity > 0 ? Color . green : Color . red) . opacity ( 0.15 ) )
254+ . foregroundStyle ( velocity > 0 ? . green : . red)
255+ . clipShape ( Capsule ( ) )
256+ }
257+
258+ private func metricCard( icon: String , label: String , value: String , color: Color ) -> some View {
259+ VStack ( alignment: . leading, spacing: 4 ) {
260+ HStack ( spacing: 4 ) {
261+ Image ( systemName: icon)
262+ . font ( . system( size: 14 ) )
263+ Text ( label)
264+ . font ( . caption2)
265+ }
266+ . foregroundStyle ( . secondary)
267+
268+ Text ( value)
269+ . font ( . title3) . fontWeight ( . bold)
270+ . foregroundStyle ( color)
271+ }
272+ . frame ( maxWidth: . infinity, alignment: . leading)
273+ . padding ( )
274+ . background ( Color ( . systemBackground) )
275+ . clipShape ( RoundedRectangle ( cornerRadius: 8 ) )
276+ }
277+
278+ private func formatDelta( _ amount: Double ) -> String {
279+ let absAmount = abs ( amount)
280+ if absAmount >= 1_000_000 {
281+ return String ( format: " $%.1fM " , amount / 1_000_000 )
282+ } else if absAmount >= 1_000 {
283+ return String ( format: " $%.0fK " , amount / 1_000 )
284+ }
285+ return " $ \( Int ( amount) ) "
286+ }
287+
288+ private func loadHistory( ) async {
289+ isLoadingHistory = true
290+ defer { isLoadingHistory = false }
291+
292+ do {
293+ let response = try await APIClient . shared. fetchCampaignHistory ( pid: campaign. pid, days: 14 )
294+ historyData = response. history
295+ } catch {
296+ print ( " Failed to load history: \( error) " )
297+ }
298+ }
299+ }
300+
301+ struct ExpandableBlurbView : View {
302+ let blurb : String
303+ @State private var isExpanded = false
304+
305+ private var shouldShowButton : Bool {
306+ blurb. count > 150
307+ }
308+
309+ var body : some View {
310+ VStack ( alignment: . leading, spacing: 8 ) {
311+ Text ( " About this project " )
312+ . font ( . subheadline) . fontWeight ( . semibold)
313+
314+ Text ( blurb)
315+ . font ( . subheadline)
316+ . foregroundStyle ( . secondary)
317+ . lineLimit ( isExpanded ? nil : 3 )
318+
319+ if shouldShowButton {
320+ Button {
321+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
322+ isExpanded. toggle ( )
323+ }
324+ } label: {
325+ HStack ( spacing: 4 ) {
326+ Text ( isExpanded ? " Show less " : " Read more " )
327+ Image ( systemName: isExpanded ? " chevron.up " : " chevron.down " )
328+ . font ( . system( size: 10 ) )
329+ }
330+ . font ( . caption) . fontWeight ( . medium)
331+ . foregroundStyle ( . accentColor)
332+ }
333+ }
334+ }
335+ . padding ( )
336+ . background ( Color ( . systemGray6) )
337+ . clipShape ( RoundedRectangle ( cornerRadius: 12 ) )
338+ }
159339}
0 commit comments