spaps 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spaps.js +1 -1
- package/package.json +1 -1
- package/src/admin-local.js +408 -0
- package/src/local-server.js +277 -1
package/bin/spaps.js
CHANGED
|
@@ -34,7 +34,7 @@ program
|
|
|
34
34
|
program
|
|
35
35
|
.command('local')
|
|
36
36
|
.description('Start local SPAPS server (no API keys required!)')
|
|
37
|
-
.option('-p, --port <port>', 'Port to run on', '
|
|
37
|
+
.option('-p, --port <port>', 'Port to run on', '3456')
|
|
38
38
|
.option('-o, --open', 'Open browser automatically', false)
|
|
39
39
|
.option('--json', 'Output in JSON format')
|
|
40
40
|
.action(async (options, command) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Admin Management for SPAPS
|
|
3
|
+
* Provides product, customer, and order management in local mode
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { v4: uuidv4 } = require('uuid');
|
|
9
|
+
|
|
10
|
+
class LocalAdminManager {
|
|
11
|
+
constructor(dataDir = './.spaps') {
|
|
12
|
+
this.dataDir = dataDir;
|
|
13
|
+
this.ensureDataDir();
|
|
14
|
+
this.loadData();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ensureDataDir() {
|
|
18
|
+
if (!fs.existsSync(this.dataDir)) {
|
|
19
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
loadData() {
|
|
24
|
+
// Load or create products
|
|
25
|
+
this.productsFile = path.join(this.dataDir, 'products.json');
|
|
26
|
+
if (fs.existsSync(this.productsFile)) {
|
|
27
|
+
this.products = JSON.parse(fs.readFileSync(this.productsFile, 'utf8'));
|
|
28
|
+
} else {
|
|
29
|
+
this.products = this.getDefaultProducts();
|
|
30
|
+
this.saveProducts();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Load or create orders
|
|
34
|
+
this.ordersFile = path.join(this.dataDir, 'orders.json');
|
|
35
|
+
if (fs.existsSync(this.ordersFile)) {
|
|
36
|
+
this.orders = JSON.parse(fs.readFileSync(this.ordersFile, 'utf8'));
|
|
37
|
+
} else {
|
|
38
|
+
this.orders = [];
|
|
39
|
+
this.saveOrders();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load or create customers
|
|
43
|
+
this.customersFile = path.join(this.dataDir, 'customers.json');
|
|
44
|
+
if (fs.existsSync(this.customersFile)) {
|
|
45
|
+
this.customers = JSON.parse(fs.readFileSync(this.customersFile, 'utf8'));
|
|
46
|
+
} else {
|
|
47
|
+
this.customers = [];
|
|
48
|
+
this.saveCustomers();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getDefaultProducts() {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
id: 'prod_local_validate',
|
|
56
|
+
price_id: 'price_local_validate',
|
|
57
|
+
name: 'Validate Tier',
|
|
58
|
+
description: 'Landing page with data capture and analytics',
|
|
59
|
+
price: 50000,
|
|
60
|
+
currency: 'usd',
|
|
61
|
+
features: [
|
|
62
|
+
'Landing page',
|
|
63
|
+
'Data capture setup',
|
|
64
|
+
'Analytics integration',
|
|
65
|
+
'Deployment'
|
|
66
|
+
],
|
|
67
|
+
active: true,
|
|
68
|
+
created_at: new Date().toISOString()
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'prod_local_prototype',
|
|
72
|
+
price_id: 'price_local_prototype',
|
|
73
|
+
name: 'Prototype Tier',
|
|
74
|
+
description: 'Clickable prototype with core user flows',
|
|
75
|
+
price: 250000,
|
|
76
|
+
currency: 'usd',
|
|
77
|
+
features: [
|
|
78
|
+
'Clickable interface',
|
|
79
|
+
'Core user flows',
|
|
80
|
+
'Demo environment',
|
|
81
|
+
'Shareable prototype link'
|
|
82
|
+
],
|
|
83
|
+
active: true,
|
|
84
|
+
created_at: new Date().toISOString()
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'prod_local_strategy',
|
|
88
|
+
price_id: 'price_local_strategy',
|
|
89
|
+
name: 'Strategy Tier',
|
|
90
|
+
description: 'Technical architecture and implementation roadmap',
|
|
91
|
+
price: 1000000,
|
|
92
|
+
currency: 'usd',
|
|
93
|
+
features: [
|
|
94
|
+
'Technical architecture design',
|
|
95
|
+
'Development roadmap',
|
|
96
|
+
'Tech stack decisions',
|
|
97
|
+
'Implementation timeline'
|
|
98
|
+
],
|
|
99
|
+
active: true,
|
|
100
|
+
created_at: new Date().toISOString()
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'prod_local_build',
|
|
104
|
+
price_id: 'price_local_build',
|
|
105
|
+
name: 'Build Tier',
|
|
106
|
+
description: 'Full application development and deployment',
|
|
107
|
+
price: 2500000,
|
|
108
|
+
currency: 'usd',
|
|
109
|
+
features: [
|
|
110
|
+
'Full application development',
|
|
111
|
+
'Database & backend',
|
|
112
|
+
'Production deployment',
|
|
113
|
+
'Technical handoff'
|
|
114
|
+
],
|
|
115
|
+
active: true,
|
|
116
|
+
created_at: new Date().toISOString()
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Product Management
|
|
122
|
+
|
|
123
|
+
saveProducts() {
|
|
124
|
+
fs.writeFileSync(this.productsFile, JSON.stringify(this.products, null, 2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
listProducts(filter = {}) {
|
|
128
|
+
let filtered = [...this.products];
|
|
129
|
+
|
|
130
|
+
if (filter.active !== undefined) {
|
|
131
|
+
filtered = filtered.filter(p => p.active === filter.active);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return filtered;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getProduct(id) {
|
|
138
|
+
return this.products.find(p => p.id === id || p.price_id === id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createProduct(data) {
|
|
142
|
+
const product = {
|
|
143
|
+
id: `prod_local_${uuidv4().substring(0, 8)}`,
|
|
144
|
+
price_id: `price_local_${uuidv4().substring(0, 8)}`,
|
|
145
|
+
name: data.name,
|
|
146
|
+
description: data.description,
|
|
147
|
+
price: data.price,
|
|
148
|
+
currency: data.currency || 'usd',
|
|
149
|
+
features: data.features || [],
|
|
150
|
+
active: true,
|
|
151
|
+
created_at: new Date().toISOString(),
|
|
152
|
+
updated_at: new Date().toISOString()
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
this.products.push(product);
|
|
156
|
+
this.saveProducts();
|
|
157
|
+
|
|
158
|
+
return product;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
updateProduct(id, updates) {
|
|
162
|
+
const index = this.products.findIndex(p => p.id === id || p.price_id === id);
|
|
163
|
+
if (index === -1) {
|
|
164
|
+
throw new Error('Product not found');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.products[index] = {
|
|
168
|
+
...this.products[index],
|
|
169
|
+
...updates,
|
|
170
|
+
updated_at: new Date().toISOString()
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.saveProducts();
|
|
174
|
+
return this.products[index];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
deleteProduct(id) {
|
|
178
|
+
const index = this.products.findIndex(p => p.id === id || p.price_id === id);
|
|
179
|
+
if (index === -1) {
|
|
180
|
+
throw new Error('Product not found');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Soft delete - just mark as inactive
|
|
184
|
+
this.products[index].active = false;
|
|
185
|
+
this.products[index].deleted_at = new Date().toISOString();
|
|
186
|
+
|
|
187
|
+
this.saveProducts();
|
|
188
|
+
return { success: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Order Management
|
|
192
|
+
|
|
193
|
+
saveOrders() {
|
|
194
|
+
fs.writeFileSync(this.ordersFile, JSON.stringify(this.orders, null, 2));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
listOrders(filter = {}) {
|
|
198
|
+
let filtered = [...this.orders];
|
|
199
|
+
|
|
200
|
+
if (filter.status) {
|
|
201
|
+
filtered = filtered.filter(o => o.status === filter.status);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (filter.customer_id) {
|
|
205
|
+
filtered = filtered.filter(o => o.customer_id === filter.customer_id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Sort by created_at descending
|
|
209
|
+
filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
210
|
+
|
|
211
|
+
return filtered;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
createOrder(data) {
|
|
215
|
+
const order = {
|
|
216
|
+
id: `order_local_${uuidv4().substring(0, 8)}`,
|
|
217
|
+
customer_id: data.customer_id || `cus_local_${uuidv4().substring(0, 8)}`,
|
|
218
|
+
customer_email: data.customer_email,
|
|
219
|
+
product_id: data.product_id,
|
|
220
|
+
price_id: data.price_id,
|
|
221
|
+
amount: data.amount,
|
|
222
|
+
currency: data.currency || 'usd',
|
|
223
|
+
status: 'pending',
|
|
224
|
+
payment_intent_id: `pi_local_${uuidv4().substring(0, 8)}`,
|
|
225
|
+
metadata: data.metadata || {},
|
|
226
|
+
created_at: new Date().toISOString(),
|
|
227
|
+
updated_at: new Date().toISOString()
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
this.orders.push(order);
|
|
231
|
+
this.saveOrders();
|
|
232
|
+
|
|
233
|
+
return order;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
updateOrderStatus(orderId, status) {
|
|
237
|
+
const order = this.orders.find(o => o.id === orderId);
|
|
238
|
+
if (!order) {
|
|
239
|
+
throw new Error('Order not found');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
order.status = status;
|
|
243
|
+
order.updated_at = new Date().toISOString();
|
|
244
|
+
|
|
245
|
+
if (status === 'completed') {
|
|
246
|
+
order.completed_at = new Date().toISOString();
|
|
247
|
+
} else if (status === 'cancelled') {
|
|
248
|
+
order.cancelled_at = new Date().toISOString();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.saveOrders();
|
|
252
|
+
return order;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Customer Management
|
|
256
|
+
|
|
257
|
+
saveCustomers() {
|
|
258
|
+
fs.writeFileSync(this.customersFile, JSON.stringify(this.customers, null, 2));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
listCustomers() {
|
|
262
|
+
return this.customers;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
createCustomer(data) {
|
|
266
|
+
const customer = {
|
|
267
|
+
id: `cus_local_${uuidv4().substring(0, 8)}`,
|
|
268
|
+
email: data.email,
|
|
269
|
+
name: data.name,
|
|
270
|
+
metadata: data.metadata || {},
|
|
271
|
+
created_at: new Date().toISOString(),
|
|
272
|
+
updated_at: new Date().toISOString()
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
this.customers.push(customer);
|
|
276
|
+
this.saveCustomers();
|
|
277
|
+
|
|
278
|
+
return customer;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Analytics
|
|
282
|
+
|
|
283
|
+
getAnalytics() {
|
|
284
|
+
const now = new Date();
|
|
285
|
+
const thirtyDaysAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
|
|
286
|
+
|
|
287
|
+
const recentOrders = this.orders.filter(o =>
|
|
288
|
+
new Date(o.created_at) > thirtyDaysAgo
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const completedOrders = recentOrders.filter(o => o.status === 'completed');
|
|
292
|
+
const totalRevenue = completedOrders.reduce((sum, o) => sum + o.amount, 0);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
total_orders: this.orders.length,
|
|
296
|
+
recent_orders: recentOrders.length,
|
|
297
|
+
completed_orders: completedOrders.length,
|
|
298
|
+
total_revenue: totalRevenue,
|
|
299
|
+
total_customers: this.customers.length,
|
|
300
|
+
total_products: this.products.filter(p => p.active).length,
|
|
301
|
+
revenue_by_product: this.getRevenueByProduct(),
|
|
302
|
+
orders_by_status: this.getOrdersByStatus(),
|
|
303
|
+
recent_activity: this.getRecentActivity()
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getRevenueByProduct() {
|
|
308
|
+
const revenue = {};
|
|
309
|
+
|
|
310
|
+
this.orders
|
|
311
|
+
.filter(o => o.status === 'completed')
|
|
312
|
+
.forEach(order => {
|
|
313
|
+
const product = this.getProduct(order.product_id);
|
|
314
|
+
if (product) {
|
|
315
|
+
if (!revenue[product.name]) {
|
|
316
|
+
revenue[product.name] = {
|
|
317
|
+
total: 0,
|
|
318
|
+
count: 0
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
revenue[product.name].total += order.amount;
|
|
322
|
+
revenue[product.name].count += 1;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return revenue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getOrdersByStatus() {
|
|
330
|
+
const statuses = {};
|
|
331
|
+
|
|
332
|
+
this.orders.forEach(order => {
|
|
333
|
+
if (!statuses[order.status]) {
|
|
334
|
+
statuses[order.status] = 0;
|
|
335
|
+
}
|
|
336
|
+
statuses[order.status]++;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return statuses;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getRecentActivity() {
|
|
343
|
+
const activities = [];
|
|
344
|
+
|
|
345
|
+
// Add recent orders
|
|
346
|
+
this.orders.slice(-5).forEach(order => {
|
|
347
|
+
activities.push({
|
|
348
|
+
type: 'order',
|
|
349
|
+
message: `New order #${order.id.substring(12)} - $${order.amount / 100}`,
|
|
350
|
+
timestamp: order.created_at
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Add recent customers
|
|
355
|
+
this.customers.slice(-3).forEach(customer => {
|
|
356
|
+
activities.push({
|
|
357
|
+
type: 'customer',
|
|
358
|
+
message: `New customer: ${customer.email}`,
|
|
359
|
+
timestamp: customer.created_at
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Sort by timestamp
|
|
364
|
+
activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
365
|
+
|
|
366
|
+
return activities.slice(0, 10);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Export/Import for migration to production
|
|
370
|
+
|
|
371
|
+
exportData() {
|
|
372
|
+
return {
|
|
373
|
+
products: this.products,
|
|
374
|
+
orders: this.orders,
|
|
375
|
+
customers: this.customers,
|
|
376
|
+
exported_at: new Date().toISOString(),
|
|
377
|
+
version: '1.0.0'
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
importData(data) {
|
|
382
|
+
if (data.products) {
|
|
383
|
+
this.products = data.products;
|
|
384
|
+
this.saveProducts();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (data.orders) {
|
|
388
|
+
this.orders = data.orders;
|
|
389
|
+
this.saveOrders();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (data.customers) {
|
|
393
|
+
this.customers = data.customers;
|
|
394
|
+
this.saveCustomers();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
success: true,
|
|
399
|
+
imported: {
|
|
400
|
+
products: data.products?.length || 0,
|
|
401
|
+
orders: data.orders?.length || 0,
|
|
402
|
+
customers: data.customers?.length || 0
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
module.exports = LocalAdminManager;
|
package/src/local-server.js
CHANGED
|
@@ -10,16 +10,19 @@ const cors = require('cors');
|
|
|
10
10
|
const chalk = require('chalk');
|
|
11
11
|
const { generateDocsHTML } = require('./docs-html');
|
|
12
12
|
const StripeLocalManager = require('./stripe-local');
|
|
13
|
+
const LocalAdminManager = require('./admin-local');
|
|
13
14
|
|
|
14
15
|
class LocalServer {
|
|
15
16
|
constructor(options = {}) {
|
|
16
|
-
this.port = options.port || process.env.PORT ||
|
|
17
|
+
this.port = options.port || process.env.PORT || 3456;
|
|
17
18
|
this.json = options.json || false;
|
|
18
19
|
this.app = express();
|
|
19
20
|
this.stripeManager = null;
|
|
21
|
+
this.adminManager = new LocalAdminManager();
|
|
20
22
|
this.setupMiddleware();
|
|
21
23
|
this.setupRoutes();
|
|
22
24
|
this.setupStripeRoutes();
|
|
25
|
+
this.setupAdminRoutes();
|
|
23
26
|
this.setupCatchAll();
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -343,6 +346,279 @@ class LocalServer {
|
|
|
343
346
|
return prices[priceId] || 10000;
|
|
344
347
|
}
|
|
345
348
|
|
|
349
|
+
setupAdminRoutes() {
|
|
350
|
+
// Admin API endpoints
|
|
351
|
+
|
|
352
|
+
// List products
|
|
353
|
+
this.app.get('/api/admin/products', (req, res) => {
|
|
354
|
+
const products = this.adminManager.listProducts();
|
|
355
|
+
res.json({ success: true, data: products });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Get single product
|
|
359
|
+
this.app.get('/api/admin/products/:id', (req, res) => {
|
|
360
|
+
try {
|
|
361
|
+
const product = this.adminManager.getProduct(req.params.id);
|
|
362
|
+
if (!product) {
|
|
363
|
+
return res.status(404).json({ success: false, error: 'Product not found' });
|
|
364
|
+
}
|
|
365
|
+
res.json({ success: true, data: product });
|
|
366
|
+
} catch (error) {
|
|
367
|
+
res.status(500).json({ success: false, error: error.message });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Create product
|
|
372
|
+
this.app.post('/api/admin/products', (req, res) => {
|
|
373
|
+
try {
|
|
374
|
+
const product = this.adminManager.createProduct(req.body);
|
|
375
|
+
res.json({ success: true, data: product });
|
|
376
|
+
} catch (error) {
|
|
377
|
+
res.status(400).json({ success: false, error: error.message });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Update product
|
|
382
|
+
this.app.put('/api/admin/products/:id', (req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const product = this.adminManager.updateProduct(req.params.id, req.body);
|
|
385
|
+
res.json({ success: true, data: product });
|
|
386
|
+
} catch (error) {
|
|
387
|
+
res.status(400).json({ success: false, error: error.message });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Delete product
|
|
392
|
+
this.app.delete('/api/admin/products/:id', (req, res) => {
|
|
393
|
+
try {
|
|
394
|
+
const result = this.adminManager.deleteProduct(req.params.id);
|
|
395
|
+
res.json(result);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
res.status(400).json({ success: false, error: error.message });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// List orders
|
|
402
|
+
this.app.get('/api/admin/orders', (req, res) => {
|
|
403
|
+
const orders = this.adminManager.listOrders(req.query);
|
|
404
|
+
res.json({ success: true, data: orders });
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Create order (for testing)
|
|
408
|
+
this.app.post('/api/admin/orders', (req, res) => {
|
|
409
|
+
try {
|
|
410
|
+
const order = this.adminManager.createOrder(req.body);
|
|
411
|
+
res.json({ success: true, data: order });
|
|
412
|
+
} catch (error) {
|
|
413
|
+
res.status(400).json({ success: false, error: error.message });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Update order status
|
|
418
|
+
this.app.patch('/api/admin/orders/:id/status', (req, res) => {
|
|
419
|
+
try {
|
|
420
|
+
const order = this.adminManager.updateOrderStatus(req.params.id, req.body.status);
|
|
421
|
+
res.json({ success: true, data: order });
|
|
422
|
+
} catch (error) {
|
|
423
|
+
res.status(400).json({ success: false, error: error.message });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Analytics dashboard
|
|
428
|
+
this.app.get('/api/admin/analytics', (req, res) => {
|
|
429
|
+
const analytics = this.adminManager.getAnalytics();
|
|
430
|
+
res.json({ success: true, data: analytics });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Export data (for migration to production)
|
|
434
|
+
this.app.get('/api/admin/export', (req, res) => {
|
|
435
|
+
const data = this.adminManager.exportData();
|
|
436
|
+
res.json({ success: true, data });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Import data
|
|
440
|
+
this.app.post('/api/admin/import', (req, res) => {
|
|
441
|
+
try {
|
|
442
|
+
const result = this.adminManager.importData(req.body);
|
|
443
|
+
res.json({ success: true, data: result });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
res.status(400).json({ success: false, error: error.message });
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Admin UI Dashboard
|
|
450
|
+
this.app.get('/admin', (req, res) => {
|
|
451
|
+
const analytics = this.adminManager.getAnalytics();
|
|
452
|
+
const products = this.adminManager.listProducts();
|
|
453
|
+
|
|
454
|
+
res.send(`
|
|
455
|
+
<!DOCTYPE html>
|
|
456
|
+
<html>
|
|
457
|
+
<head>
|
|
458
|
+
<title>SPAPS Admin - Local Mode</title>
|
|
459
|
+
<style>
|
|
460
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
461
|
+
body { font-family: system-ui; background: #f5f5f5; }
|
|
462
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 2rem; }
|
|
463
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
|
464
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin: 2rem 0; }
|
|
465
|
+
.card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
466
|
+
.card h3 { margin-bottom: 1rem; color: #333; }
|
|
467
|
+
.stat { font-size: 2rem; font-weight: bold; color: #667eea; }
|
|
468
|
+
.label { color: #666; font-size: 0.9rem; }
|
|
469
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
470
|
+
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; }
|
|
471
|
+
th { background: #f9f9f9; font-weight: 600; }
|
|
472
|
+
.btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
|
|
473
|
+
.btn:hover { background: #764ba2; }
|
|
474
|
+
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem; }
|
|
475
|
+
.badge-success { background: #10b981; color: white; }
|
|
476
|
+
.badge-pending { background: #f59e0b; color: white; }
|
|
477
|
+
.nav { display: flex; gap: 2rem; margin-bottom: 2rem; }
|
|
478
|
+
.nav a { color: #667eea; text-decoration: none; font-weight: 500; }
|
|
479
|
+
.nav a:hover { text-decoration: underline; }
|
|
480
|
+
</style>
|
|
481
|
+
</head>
|
|
482
|
+
<body>
|
|
483
|
+
<div class="header">
|
|
484
|
+
<div class="container">
|
|
485
|
+
<h1>🍠 SPAPS Admin Dashboard</h1>
|
|
486
|
+
<p>Local Development Mode - Managing test data</p>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
<div class="container">
|
|
491
|
+
<div class="nav">
|
|
492
|
+
<a href="/admin">Dashboard</a>
|
|
493
|
+
<a href="/api/admin/products">Products API</a>
|
|
494
|
+
<a href="/api/admin/orders">Orders API</a>
|
|
495
|
+
<a href="/api/admin/export">Export Data</a>
|
|
496
|
+
<a href="/docs">Documentation</a>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div class="grid">
|
|
500
|
+
<div class="card">
|
|
501
|
+
<h3>Total Revenue</h3>
|
|
502
|
+
<div class="stat">$${(analytics.total_revenue / 100).toLocaleString()}</div>
|
|
503
|
+
<div class="label">From ${analytics.completed_orders} orders</div>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<div class="card">
|
|
507
|
+
<h3>Active Products</h3>
|
|
508
|
+
<div class="stat">${analytics.total_products}</div>
|
|
509
|
+
<div class="label">Available for purchase</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<div class="card">
|
|
513
|
+
<h3>Customers</h3>
|
|
514
|
+
<div class="stat">${analytics.total_customers}</div>
|
|
515
|
+
<div class="label">Total registered</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div class="card">
|
|
519
|
+
<h3>Recent Orders</h3>
|
|
520
|
+
<div class="stat">${analytics.recent_orders}</div>
|
|
521
|
+
<div class="label">Last 30 days</div>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div class="card">
|
|
526
|
+
<h3>Products</h3>
|
|
527
|
+
<table>
|
|
528
|
+
<thead>
|
|
529
|
+
<tr>
|
|
530
|
+
<th>Name</th>
|
|
531
|
+
<th>Price</th>
|
|
532
|
+
<th>Status</th>
|
|
533
|
+
<th>Actions</th>
|
|
534
|
+
</tr>
|
|
535
|
+
</thead>
|
|
536
|
+
<tbody>
|
|
537
|
+
${products.map(p => `
|
|
538
|
+
<tr>
|
|
539
|
+
<td><strong>${p.name}</strong><br><small>${p.description}</small></td>
|
|
540
|
+
<td>$${(p.price / 100).toLocaleString()}</td>
|
|
541
|
+
<td><span class="badge badge-${p.active ? 'success' : 'pending'}">${p.active ? 'Active' : 'Inactive'}</span></td>
|
|
542
|
+
<td>
|
|
543
|
+
<button class="btn" onclick="editProduct('${p.id}')">Edit</button>
|
|
544
|
+
</td>
|
|
545
|
+
</tr>
|
|
546
|
+
`).join('')}
|
|
547
|
+
</tbody>
|
|
548
|
+
</table>
|
|
549
|
+
|
|
550
|
+
<div style="margin-top: 1rem;">
|
|
551
|
+
<button class="btn" onclick="addProduct()">+ Add Product</button>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<div class="card" style="margin-top: 2rem;">
|
|
556
|
+
<h3>Recent Activity</h3>
|
|
557
|
+
<table>
|
|
558
|
+
<tbody>
|
|
559
|
+
${analytics.recent_activity.map(a => `
|
|
560
|
+
<tr>
|
|
561
|
+
<td>${a.message}</td>
|
|
562
|
+
<td style="text-align: right; color: #999;">${new Date(a.timestamp).toLocaleString()}</td>
|
|
563
|
+
</tr>
|
|
564
|
+
`).join('')}
|
|
565
|
+
</tbody>
|
|
566
|
+
</table>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div class="card" style="margin-top: 2rem; background: #fef3c7;">
|
|
570
|
+
<h3>💡 Local Mode Notice</h3>
|
|
571
|
+
<p style="margin-top: 1rem;">
|
|
572
|
+
You're running in local development mode. All data is stored locally in <code>.spaps/</code> directory.
|
|
573
|
+
</p>
|
|
574
|
+
<p style="margin-top: 0.5rem;">
|
|
575
|
+
To migrate to production:
|
|
576
|
+
</p>
|
|
577
|
+
<ol style="margin: 1rem 0 0 2rem;">
|
|
578
|
+
<li>Export your data: <code>GET /api/admin/export</code></li>
|
|
579
|
+
<li>Set up Stripe products in production</li>
|
|
580
|
+
<li>Import customer data to production database</li>
|
|
581
|
+
<li>Update environment variables</li>
|
|
582
|
+
</ol>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<script>
|
|
587
|
+
function editProduct(id) {
|
|
588
|
+
window.location.href = '/api/admin/products/' + id;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function addProduct() {
|
|
592
|
+
const name = prompt('Product name:');
|
|
593
|
+
if (!name) return;
|
|
594
|
+
|
|
595
|
+
const price = prompt('Price (in dollars):');
|
|
596
|
+
if (!price) return;
|
|
597
|
+
|
|
598
|
+
fetch('/api/admin/products', {
|
|
599
|
+
method: 'POST',
|
|
600
|
+
headers: { 'Content-Type': 'application/json' },
|
|
601
|
+
body: JSON.stringify({
|
|
602
|
+
name,
|
|
603
|
+
description: prompt('Description:') || '',
|
|
604
|
+
price: Math.round(parseFloat(price) * 100)
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
.then(r => r.json())
|
|
608
|
+
.then(data => {
|
|
609
|
+
if (data.success) {
|
|
610
|
+
alert('Product created!');
|
|
611
|
+
location.reload();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
</script>
|
|
616
|
+
</body>
|
|
617
|
+
</html>
|
|
618
|
+
`);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
346
622
|
setupCatchAll() {
|
|
347
623
|
// Catch-all for unimplemented routes
|
|
348
624
|
this.app.use((req, res) => {
|