-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathui.js
More file actions
320 lines (287 loc) · 11.2 KB
/
Copy pathui.js
File metadata and controls
320 lines (287 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
'use strict';
/**
* All Discord presentation lives here: the storefront message, the per-product
* detail card, the pending / already-pending / delivered / failed / error
* replies, the buyer's license-key DM, and the compact embeds posted to the log
* channel.
*
* Keeping the UI in one module means the flow logic in index.js stays focused on
* the Vito integration rather than on building embeds.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const {
EmbedBuilder,
ActionRowBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
} = require('discord.js');
const PRODUCTS = require('./products');
const COLORS = {
BRAND: 0x5865f2,
OK: 0x57f287,
WARN: 0xfee75c,
ERR: 0xed4245,
};
// Discord hard limits we render against.
const LABEL_MAX = 100;
const DESC_MAX = 100;
// Storefront banner (attached as a file so it works without any image host).
// Existence is checked ONCE at load — it's a deploy asset, not runtime state.
const BANNER_NAME = 'vito-banner.png';
const BANNER_PATH = path.join(__dirname, '..', 'assets', BANNER_NAME);
const BANNER_EXISTS = fs.existsSync(BANNER_PATH);
// Discord caps a string select at 25 options and an embed at 25 fields.
const SELECT_MAX = 25;
// Presentation-only emoji per category. Categories themselves come from
// products.json; this just maps a known category name to its storefront icon.
const CATEGORY_EMOJI = {
Discord: '🎮',
'Vetox Premium': '✨',
Credits: '💳',
};
const categoryEmoji = (category) => CATEGORY_EMOJI[category] || '📂';
const vito = (n) => `${Number(n).toLocaleString('en-US')} Vito`;
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
// Discord custom-emoji markup: <:name:id> or <a:name:id> (animated).
const CUSTOM_EMOJI = /<(a?):(\w{2,32}):(\d+)>/g;
// Custom emoji render inside embed text but NOT in select-menu option labels —
// there they show as raw "<a:Nitro:123…>". For a label, replace each emoji token
// with its readable name so "<a:Nitro:1> — 1 month" becomes "Nitro — 1 month".
function toPlainLabel(name) {
const plain = name.replace(CUSTOM_EMOJI, (_m, _a, emojiName) => emojiName).replace(/\s+/g, ' ').trim();
return truncate(plain || name.trim() || '(unnamed)', LABEL_MAX);
}
// The first custom emoji in a name, as an emoji resolvable for setEmoji(), or null.
function firstEmoji(name) {
CUSTOM_EMOJI.lastIndex = 0;
const m = CUSTOM_EMOJI.exec(name);
return m ? { animated: m[1] === 'a', name: m[2], id: m[3] } : null;
}
const truncate = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
// Generates a placeholder license key. This is NOT a real product key — wire your
// own fulfilment here (issue/reserve a real key) once the payment is confirmed.
// See `completeOrder` in src/index.js for where delivery happens.
function newLicenseKey() {
const group = () => crypto.randomBytes(4).toString('hex').toUpperCase().slice(0, 5);
return `VITO-${group()}-${group()}-${group()}-${group()}`;
}
// Group products by category, preserving first-seen category order.
function groupByCategory(products) {
const groups = new Map();
for (const p of products) {
if (!groups.has(p.category)) groups.set(p.category, []);
groups.get(p.category).push(p);
}
return groups;
}
// The persistent storefront message: a compact banner embed + the product picker.
// Individual products live in the select menu, so the embed stays short — a one
// line pitch, the category tiles, and a call to action.
function shopMessage() {
const groups = groupByCategory(PRODUCTS);
const availableCount = PRODUCTS.filter((p) => p.available).length;
const categoryCount = groups.size;
const embed = new EmbedBuilder()
.setColor(COLORS.BRAND)
.setTitle('🛒 Vito Store')
.setDescription(
'Buy digital products instantly with your **Vito balance**.\n\n' +
'**Select a product from the menu below to get started.**',
)
.setFooter({
text: `Vito Store • ${plural(availableCount, 'Product')} • ${plural(categoryCount, 'Category', 'Categories')}`,
});
// One compact tile per category (dynamic — reflects the real catalogue). Shows
// the category and how many products it holds, never the products themselves.
// Capped at the embed's 25-field limit.
for (const [category, items] of [...groups].slice(0, SELECT_MAX)) {
const count = items.filter((p) => p.available).length;
embed.addFields({
name: `${categoryEmoji(category)} ${category}`,
value: plural(count, 'product'),
inline: true,
});
}
// Attach the store banner as the embed image; degrade gracefully if it's missing.
const files = [];
if (BANNER_EXISTS) {
embed.setImage(`attachment://${BANNER_NAME}`);
files.push(new AttachmentBuilder(BANNER_PATH, { name: BANNER_NAME }));
}
// Only sellable products go in the picker, so an unavailable item can't be
// selected at all. Capped at Discord's 25-option limit (a 26th option makes the
// whole send fail, which would hang the deferred /shop reply).
const sellable = PRODUCTS.filter((p) => p.available);
if (sellable.length > SELECT_MAX) {
console.warn(
`[ui] ${sellable.length} available products exceeds Discord's ${SELECT_MAX}-option select limit — showing the first ${SELECT_MAX}.`,
);
}
const options = sellable.slice(0, SELECT_MAX).map((p) => {
const option = new StringSelectMenuOptionBuilder()
.setLabel(toPlainLabel(p.name))
.setDescription(truncate(`${p.category} • ${vito(p.price)}`, DESC_MAX))
.setValue(p.id);
const emoji = firstEmoji(p.name);
if (emoji) option.setEmoji(emoji);
return option;
});
const components = [];
if (options.length) {
const menu = new StringSelectMenuBuilder()
.setCustomId('shop_select')
.setPlaceholder('Choose a product to buy…')
.addOptions(options);
components.push(new ActionRowBuilder().addComponents(menu));
}
return { embeds: [embed], components, files };
}
// The (ephemeral) product detail with the Buy button.
function productDetail(product) {
const embed = new EmbedBuilder()
.setColor(product.available ? COLORS.BRAND : COLORS.WARN)
.setTitle(product.name)
.setDescription(product.description)
.setThumbnail(product.imageUrl)
.addFields(
{ name: 'Price', value: vito(product.price), inline: true },
{ name: 'Category', value: product.category, inline: true },
)
.setFooter({ text: 'You’ll confirm payment with your PIN on vetox.io' });
if (!product.available) {
embed.addFields({ name: 'Availability', value: '🚫 Currently unavailable' });
return { embeds: [embed], components: [] };
}
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(`buy:${product.id}`)
.setStyle(ButtonStyle.Success)
.setLabel(`Buy for ${vito(product.price)}`)
.setEmoji('🪙'),
);
return { embeds: [embed], components: [row] };
}
// A link-button row for the confirmUrl — never rendered when the URL is absent,
// so we never show a broken button.
function confirmRow(confirmUrl) {
if (!confirmUrl) return [];
return [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setStyle(ButtonStyle.Link)
.setURL(confirmUrl)
.setLabel('Complete Payment')
.setEmoji('🔐'),
),
];
}
// Shown after /deduct succeeds — links to the secure PIN page.
function pendingReply(order, confirmUrl) {
const embed = new EmbedBuilder()
.setColor(COLORS.WARN)
.setTitle('⏳ Payment requested')
.setDescription(
`A payment request for **${order.productName}** (${vito(order.amount)}) was created.\n\n` +
'📩 The server’s bot just **DMed you** a secure confirmation link. You can also ' +
'use the button below — sign in and enter your **PIN** to complete the purchase.\n\n' +
'Your license key will be delivered here automatically once payment is confirmed.',
)
.setFooter({ text: `Order ${order.id}` });
return { embeds: [embed], components: confirmRow(confirmUrl) };
}
// Shown when the buyer clicks Buy again while a request is still in flight.
function alreadyPendingReply(order, confirmUrl) {
const embed = new EmbedBuilder()
.setColor(COLORS.WARN)
.setTitle('⏳ Payment already in progress')
.setDescription(
`You already have an open request for **${order.productName}** (${vito(order.amount)}).\n\n` +
(confirmUrl
? 'Finish that one — check your DMs or use the button below. '
: 'Finish that one — check your DMs for the confirmation link. ') +
'We didn’t start a second charge, so you won’t be billed twice.',
)
.setFooter({ text: `Order ${order.id}` });
return { embeds: [embed], components: confirmRow(confirmUrl) };
}
function deliveredReply(order) {
const embed = new EmbedBuilder()
.setColor(COLORS.OK)
.setTitle('✅ Purchase complete!')
.setDescription(
`Your purchase of **${order.productName}** is confirmed. Here is your license key:`,
)
.addFields(
{ name: 'License key', value: `\`\`\`\n${order.licenseKey}\n\`\`\`` },
{ name: 'Amount', value: vito(order.amount), inline: true },
{ name: 'Delivered via', value: order.deliveredVia || '—', inline: true },
)
.setFooter({ text: `Order ${order.id}` });
return { embeds: [embed], components: [] };
}
function licenseDM(order) {
const embed = new EmbedBuilder()
.setColor(COLORS.OK)
.setTitle('🎉 Your Vito Store purchase')
.setDescription(
`Thanks for your purchase of **${order.productName}**! Your license key is below.`,
)
.addFields(
{ name: 'License key', value: `\`\`\`\n${order.licenseKey}\n\`\`\`` },
{ name: 'Amount', value: vito(order.amount), inline: true },
)
.setFooter({ text: 'Vito Store • keep this key safe' });
return { embeds: [embed] };
}
function failedReply(order, reason) {
const embed = new EmbedBuilder()
.setColor(COLORS.ERR)
.setTitle('Payment not completed')
.setDescription(
`Your payment request for **${order.productName}** did not complete.\n\n**Reason:** ${reason}`,
)
.setFooter({ text: `Order ${order.id}` });
return { embeds: [embed], components: [] };
}
function errorReply(message) {
const embed = new EmbedBuilder()
.setColor(COLORS.ERR)
.setTitle('Could not start the purchase')
.setDescription(message);
return { embeds: [embed], components: [] };
}
// Compact embed posted to the dedicated log channel for each flow event.
function logEmbed(order, message, color = COLORS.BRAND) {
return new EmbedBuilder()
.setColor(color)
.setAuthor({ name: `Order ${order.id}` })
.setDescription(message)
.addFields(
{ name: 'Product', value: order.productName, inline: true },
{ name: 'Amount', value: vito(order.amount), inline: true },
{ name: 'Buyer', value: `<@${order.buyerDiscordId}>`, inline: true },
{ name: 'Status', value: order.status, inline: true },
)
.setTimestamp();
}
module.exports = {
COLORS,
vito,
toPlainLabel,
firstEmoji,
newLicenseKey,
shopMessage,
productDetail,
pendingReply,
alreadyPendingReply,
deliveredReply,
licenseDM,
failedReply,
errorReply,
logEmbed,
};