Skip to main content

max / makenotwork

6.6 KB · 206 lines History Blame Raw
1 //! Promo code management screen — list, create, delete codes.
2
3 use makeover_tui::makeover_layout::{Column, Priority, Width};
4 use makeover_tui::table::{self, Cell, Sizing, TableStyle};
5 use ratatui::Frame;
6 use ratatui::layout::{Constraint, Layout, Rect};
7 use ratatui::style::{Modifier, Style};
8 use ratatui::text::{Line, Span};
9 use ratatui::widgets::{Block, Borders, Paragraph, TableState};
10
11 use super::App;
12 use crate::currency::Currency;
13 use crate::format;
14
15 /// The promo code columns. A code and what it takes off are the row; what it
16 /// applies to and how many times it has been used qualify it.
17 const CODE_COLUMNS: [Column<'static>; 4] = [
18 Column {
19 name: "Code",
20 width: Width::Fill,
21 priority: Priority::Essential,
22 sortable: false,
23 sorted: None,
24 },
25 Column {
26 name: "Discount",
27 width: Width::Fixed,
28 priority: Priority::Essential,
29 sortable: false,
30 sorted: None,
31 },
32 Column {
33 name: "Scope",
34 width: Width::Fixed,
35 priority: Priority::Secondary,
36 sortable: false,
37 sorted: None,
38 },
39 Column {
40 name: "Uses",
41 width: Width::Fixed,
42 priority: Priority::Optional,
43 sortable: false,
44 sorted: None,
45 },
46 ];
47
48 /// The tracks the hand-written `Constraint`s carried.
49 const CODE_SIZING: Sizing<'static> = Sizing {
50 lengths: &[("Code", 16), ("Discount", 12), ("Scope", 20), ("Uses", 10)],
51 fallback: 10,
52 };
53
54 pub(crate) fn render(frame: &mut Frame, app: &App) {
55 let area = frame.area();
56
57 let title = Line::from(vec![
58 Span::styled(
59 " Makenot.work ",
60 Style::default().add_modifier(Modifier::BOLD),
61 ),
62 Span::raw(" -- "),
63 Span::styled("Promo Codes", Style::default().add_modifier(Modifier::BOLD)),
64 Span::raw(" "),
65 ]);
66
67 let block = Block::default()
68 .title(title)
69 .borders(Borders::ALL)
70 .border_style(Style::default().fg(app.theme.line_border));
71
72 let inner = block.inner(area);
73 frame.render_widget(block, area);
74
75 let chunks = Layout::vertical([
76 Constraint::Length(1), // spacer
77 Constraint::Length(1), // section header
78 Constraint::Min(3), // code list
79 Constraint::Length(1), // status line
80 Constraint::Length(1), // keybindings
81 ])
82 .split(inner);
83
84 // Section header
85 let count = app.promo_codes.len();
86 let header = Paragraph::new(Line::from(vec![
87 Span::raw(" "),
88 Span::styled("Codes", Style::default().add_modifier(Modifier::BOLD)),
89 if count == 0 {
90 Span::raw("")
91 } else {
92 Span::raw(format!(" ({count})"))
93 },
94 ]));
95 frame.render_widget(header, chunks[1]);
96
97 // Code list
98 if app.loading {
99 let loading = Paragraph::new(" Loading...");
100 frame.render_widget(loading, chunks[2]);
101 } else if app.promo_codes.is_empty() {
102 let empty = Paragraph::new(" No promo codes. Press [n] to create one.");
103 frame.render_widget(empty, chunks[2]);
104 } else {
105 render_code_table(frame, app, chunks[2]);
106 }
107
108 // Status line
109 if let Some(ref status) = app.promo_status {
110 let style = if status.starts_with("Error") {
111 Style::default().fg(app.theme.status_danger)
112 } else {
113 Style::default().fg(app.theme.status_success)
114 };
115 let status_line = Paragraph::new(Line::from(vec![
116 Span::raw(" "),
117 Span::styled(status.as_str(), style),
118 ]));
119 frame.render_widget(status_line, chunks[3]);
120 }
121
122 // Keybindings
123 let editing = app.promo_editing_step.is_some();
124 let mut key_spans = vec![Span::raw(" ")];
125
126 if editing {
127 key_spans.extend([
128 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
129 Span::raw(" Confirm "),
130 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
131 Span::raw(" Cancel"),
132 ]);
133 } else {
134 key_spans.extend([
135 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
136 Span::raw(" Nav "),
137 Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
138 Span::raw(" New "),
139 ]);
140 if !app.promo_codes.is_empty() {
141 key_spans.extend([
142 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
143 Span::raw(" Delete "),
144 ]);
145 }
146 key_spans.extend([
147 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
148 Span::raw(" Refresh "),
149 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
150 Span::raw(" Back"),
151 ]);
152 }
153
154 let keys =
155 Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
156 frame.render_widget(keys, chunks[4]);
157 }
158
159 fn render_code_table(frame: &mut Frame, app: &App, area: Rect) {
160 let area = super::indent(area, 2);
161
162 let rows: Vec<Vec<Cell>> = app
163 .promo_codes
164 .iter()
165 .map(|code| {
166 let discount = format_discount(
167 code.discount_type.as_deref(),
168 code.discount_value,
169 app.currency(),
170 );
171 let scope = code
172 .item_title
173 .as_deref()
174 .or(code.project_title.as_deref())
175 .unwrap_or("All items");
176 let uses = match code.max_uses {
177 Some(max) => format!("{}/{}", code.use_count, max),
178 None => code.use_count.to_string(),
179 };
180
181 vec![
182 Cell::new("Code", code.code.as_str()),
183 Cell::new("Discount", discount),
184 Cell::new("Scope", scope),
185 Cell::new("Uses", uses),
186 ]
187 })
188 .collect();
189
190 let style = TableStyle::from_theme(&app.theme);
191 let table = table::table(&CODE_COLUMNS, &rows, &CODE_SIZING, &style, area.width);
192 let mut state = TableState::default().with_selected(Some(app.selected_index));
193 frame.render_stateful_widget(table, area, &mut state);
194 }
195
196 /// A promo discount: a percentage, or an amount off in the creator's own
197 /// currency. A fixed discount is denominated the same as the price it comes
198 /// off, so it takes the same currency rather than a bare dollar sign.
199 fn format_discount(dtype: Option<&str>, value: Option<i32>, currency: Currency) -> String {
200 match (dtype, value) {
201 (Some("percentage"), Some(v)) => format!("{v}% off"),
202 (Some("fixed"), Some(v)) => format!("{} off", format::format_cents(i64::from(v), currency)),
203 _ => "Free".to_string(),
204 }
205 }
206