Skip to main content

max / makenotwork

5.5 KB · 175 lines History Blame Raw
1 //! Promo code management screen — list, create, delete codes.
2
3 use ratatui::Frame;
4 use ratatui::layout::{Constraint, Layout};
5 use ratatui::style::{Color, Modifier, Style};
6 use ratatui::text::{Line, Span};
7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8
9 use super::App;
10 use super::widgets;
11 use crate::currency::Currency;
12 use crate::format;
13
14 pub(crate) fn render(frame: &mut Frame, app: &App) {
15 let area = frame.area();
16
17 let title = Line::from(vec![
18 Span::styled(
19 " Makenot.work ",
20 Style::default().add_modifier(Modifier::BOLD),
21 ),
22 Span::raw(" -- "),
23 Span::styled("Promo Codes", Style::default().add_modifier(Modifier::BOLD)),
24 Span::raw(" "),
25 ]);
26
27 let block = Block::default()
28 .title(title)
29 .borders(Borders::ALL)
30 .border_style(Style::default().fg(Color::Gray));
31
32 let inner = block.inner(area);
33 frame.render_widget(block, area);
34
35 let chunks = Layout::vertical([
36 Constraint::Length(1), // spacer
37 Constraint::Length(1), // section header
38 Constraint::Min(3), // code list
39 Constraint::Length(1), // status line
40 Constraint::Length(1), // keybindings
41 ])
42 .split(inner);
43
44 // Section header
45 let count = app.promo_codes.len();
46 let header = Paragraph::new(Line::from(vec![
47 Span::raw(" "),
48 Span::styled("Codes", Style::default().add_modifier(Modifier::BOLD)),
49 if count == 0 {
50 Span::raw("")
51 } else {
52 Span::raw(format!(" ({count})"))
53 },
54 ]));
55 frame.render_widget(header, chunks[1]);
56
57 // Code list
58 if app.loading {
59 let loading = Paragraph::new(" Loading...");
60 frame.render_widget(loading, chunks[2]);
61 } else if app.promo_codes.is_empty() {
62 let empty = Paragraph::new(" No promo codes. Press [n] to create one.");
63 frame.render_widget(empty, chunks[2]);
64 } else {
65 render_code_table(frame, app, chunks[2]);
66 }
67
68 // Status line
69 if let Some(ref status) = app.promo_status {
70 let style = if status.starts_with("Error") {
71 Style::default().fg(Color::Red)
72 } else {
73 Style::default().fg(Color::Green)
74 };
75 let status_line = Paragraph::new(Line::from(vec![
76 Span::raw(" "),
77 Span::styled(status.as_str(), style),
78 ]));
79 frame.render_widget(status_line, chunks[3]);
80 }
81
82 // Keybindings
83 let editing = app.promo_editing_step.is_some();
84 let mut key_spans = vec![Span::raw(" ")];
85
86 if editing {
87 key_spans.extend([
88 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
89 Span::raw(" Confirm "),
90 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
91 Span::raw(" Cancel"),
92 ]);
93 } else {
94 key_spans.extend([
95 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
96 Span::raw(" Nav "),
97 Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
98 Span::raw(" New "),
99 ]);
100 if !app.promo_codes.is_empty() {
101 key_spans.extend([
102 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
103 Span::raw(" Delete "),
104 ]);
105 }
106 key_spans.extend([
107 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
108 Span::raw(" Refresh "),
109 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
110 Span::raw(" Back"),
111 ]);
112 }
113
114 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
115 frame.render_widget(keys, chunks[4]);
116 }
117
118 fn render_code_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
119 let rows: Vec<Row> = app
120 .promo_codes
121 .iter()
122 .enumerate()
123 .map(|(i, code)| {
124 let discount = format_discount(
125 code.discount_type.as_deref(),
126 code.discount_value,
127 app.currency(),
128 );
129 let scope = code
130 .item_title
131 .as_deref()
132 .or(code.project_title.as_deref())
133 .unwrap_or("All items");
134 let uses = match code.max_uses {
135 Some(max) => format!("{}/{}", code.use_count, max),
136 None => code.use_count.to_string(),
137 };
138
139 Row::new(vec![
140 format!(" {}", code.code),
141 discount,
142 scope.to_string(),
143 uses,
144 ])
145 .style(widgets::selected_style(i, Some(app.selected_index)))
146 })
147 .collect();
148
149 let widths = [
150 Constraint::Min(16),
151 Constraint::Length(12),
152 Constraint::Length(20),
153 Constraint::Length(10),
154 ];
155
156 widgets::render_table(
157 frame,
158 area,
159 &[" Code", "Discount", "Scope", "Uses"],
160 &widths,
161 rows,
162 );
163 }
164
165 /// A promo discount: a percentage, or an amount off in the creator's own
166 /// currency. A fixed discount is denominated the same as the price it comes
167 /// off, so it takes the same currency rather than a bare dollar sign.
168 fn format_discount(dtype: Option<&str>, value: Option<i32>, currency: Currency) -> String {
169 match (dtype, value) {
170 (Some("percentage"), Some(v)) => format!("{v}% off"),
171 (Some("fixed"), Some(v)) => format!("{} off", format::format_cents(i64::from(v), currency)),
172 _ => "Free".to_string(),
173 }
174 }
175