Skip to content

Commit 597dbb4

Browse files
committed
feat(C-2): shop management UI and Foundry purchase flow
Add transaction history to shop inventory widget, create standalone transaction log widget for entity pages, and enhance Foundry shop window with purchase flow via Chronicle API. - Add transaction history toggle and display to shop_inventory.js - Create transaction_log.js widget for entity page layout blocks - Register transaction_log block type in block registry (armory addon) - Add blockTransactionLog templ component as widget mount point - Enhance Foundry shop-widget.mjs with purchase execution - Add buy button click handlers and stock validation - Include relation ID and currency in drag-and-drop data https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent 7c77891 commit 597dbb4

5 files changed

Lines changed: 470 additions & 5 deletions

File tree

foundry-module/scripts/shop-widget.mjs

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,19 @@ export class ShopWidget {
5959
* @param {object} msg
6060
*/
6161
onMessage(msg) {
62-
if (msg.type !== 'entity.updated') return;
62+
// Refresh on entity updates.
63+
if (msg.type === 'entity.updated') {
64+
const entityId = msg.payload?.id;
65+
if (entityId && this._openWindows.has(entityId)) {
66+
this._openWindows.get(entityId).refresh();
67+
}
68+
}
6369

64-
// Refresh any open shop windows for the updated entity.
65-
const entityId = msg.payload?.id;
66-
if (entityId && this._openWindows.has(entityId)) {
67-
this._openWindows.get(entityId).refresh();
70+
// Refresh on relation changes (stock depleted after purchase).
71+
if (msg.type === 'relation.metadata_updated' || msg.type === 'relation.deleted') {
72+
for (const window of this._openWindows.values()) {
73+
window.refresh();
74+
}
6875
}
6976
}
7077

@@ -153,12 +160,14 @@ class ShopWindow extends HandlebarsApplicationMixin(ApplicationV2) {
153160
.filter((r) => r.metadata && (r.metadata.price !== undefined || r.metadata.quantity !== undefined))
154161
.map((r) => ({
155162
id: r.targetEntityId,
163+
relationId: r.id,
156164
name: r.targetEntityName || 'Unknown Item',
157165
image_path: null, // Chronicle entities use FA icons, not image URLs.
158166
icon: r.targetEntityIcon || 'fa-box',
159167
color: r.targetEntityColor || '#6b7280',
160168
type: r.targetEntityType || '',
161169
price: r.metadata.price,
170+
currency: r.metadata.currency || 'gp',
162171
quantity: r.metadata.quantity,
163172
in_stock: r.metadata.in_stock !== false,
164173
description: r.metadata.description || '',
@@ -204,6 +213,7 @@ class ShopWindow extends HandlebarsApplicationMixin(ApplicationV2) {
204213
itemEl.setAttribute('draggable', 'true');
205214
itemEl.addEventListener('dragstart', (event) => {
206215
const itemId = itemEl.dataset.itemId;
216+
const relationId = itemEl.dataset.relationId;
207217
const itemData = this._inventory.find((item) => item.id === itemId) || {};
208218
event.dataTransfer.setData(
209219
'text/plain',
@@ -216,12 +226,78 @@ class ShopWindow extends HandlebarsApplicationMixin(ApplicationV2) {
216226
[FLAG_SCOPE]: {
217227
shopEntityId: this._entityId,
218228
chronicleItemId: itemId,
229+
shopRelationId: relationId ? parseInt(relationId) : undefined,
230+
shopPrice: itemData.price,
231+
shopCurrency: itemData.currency || 'gp',
219232
},
220233
},
221234
})
222235
);
223236
});
224237
});
238+
239+
// Buy button click handler.
240+
el.querySelectorAll('.shop-buy-btn').forEach((btn) => {
241+
btn.addEventListener('click', async () => {
242+
const itemId = btn.dataset.itemId;
243+
const relationId = btn.dataset.relationId;
244+
const itemData = this._inventory.find((item) => item.id === itemId) || {};
245+
await this._executePurchase(itemData, relationId);
246+
});
247+
});
248+
}
249+
250+
/**
251+
* Execute a purchase via the Chronicle API.
252+
* Creates a transaction record and decrements shop stock.
253+
* @param {object} itemData - Shop inventory item.
254+
* @param {string} relationId - Shop inventory relation ID.
255+
* @private
256+
*/
257+
async _executePurchase(itemData, relationId) {
258+
// Determine the buyer (currently controlled character or selected actor).
259+
const buyer = game.user.character;
260+
if (!buyer) {
261+
ui.notifications.warn('No character assigned — select a character in User Configuration to purchase items.');
262+
return;
263+
}
264+
265+
const buyerEntityId = buyer.getFlag(FLAG_SCOPE, 'entityId');
266+
if (!buyerEntityId) {
267+
ui.notifications.warn('Your character is not synced with Chronicle. Sync it first to make purchases.');
268+
return;
269+
}
270+
271+
// Check stock.
272+
if (itemData.quantity !== undefined && itemData.quantity !== null && itemData.quantity <= 0) {
273+
ui.notifications.warn(`${itemData.name} is out of stock.`);
274+
return;
275+
}
276+
277+
try {
278+
const result = await this._api.post('/armory/purchase', {
279+
shop_entity_id: this._entityId,
280+
item_entity_id: itemData.id,
281+
buyer_entity_id: buyerEntityId,
282+
relation_id: relationId ? parseInt(relationId) : 0,
283+
quantity: 1,
284+
price_paid: itemData.price ? `${itemData.price} ${itemData.currency || 'gp'}` : '',
285+
currency: itemData.currency || 'gp',
286+
price_numeric: typeof itemData.price === 'number' ? itemData.price : parseFloat(itemData.price) || 0,
287+
transaction_type: 'purchase',
288+
});
289+
290+
if (result) {
291+
ui.notifications.info(`${buyer.name} purchased ${itemData.name}!`);
292+
293+
// Refresh shop window to show updated stock.
294+
await this.refresh();
295+
}
296+
} catch (err) {
297+
console.error('Chronicle: Purchase failed', err);
298+
const msg = err?.message || 'Purchase failed';
299+
ui.notifications.error(`Purchase failed: ${msg}`);
300+
}
225301
}
226302

227303
async close(options) {

internal/plugins/entities/block_registry_core.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ func RegisterCoreBlocks(r *BlockRegistry) {
8585
return blockInventory(ctx.CC, ctx.Entity, ctx.CSRFToken)
8686
})
8787

88+
r.Register(BlockMeta{
89+
Type: "transaction_log", Label: "Transaction Log", Icon: "fa-receipt",
90+
Description: "Purchase and sale history for shops", Addon: "armory",
91+
}, func(ctx BlockRenderContext) templ.Component {
92+
return blockTransactionLog(ctx.CC, ctx.Entity)
93+
})
94+
8895
r.Register(BlockMeta{
8996
Type: "text_block", Label: "Text Block", Icon: "fa-align-left",
9097
Description: "Custom static HTML content",

internal/plugins/entities/show.templ

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,17 @@ templ blockShopInventory(cc *campaigns.CampaignContext, entity *Entity, csrfToke
430430
></div>
431431
}
432432

433+
// blockTransactionLog renders a transaction history widget mount point.
434+
// Shows purchase, sale, gift, and restock transactions for a shop entity.
435+
templ blockTransactionLog(cc *campaigns.CampaignContext, entity *Entity) {
436+
<div
437+
data-widget="transaction_log"
438+
data-transactions-endpoint={ fmt.Sprintf("/campaigns/%s/armory/shops/%s/transactions", cc.Campaign.ID, entity.ID) }
439+
data-campaign-url={ fmt.Sprintf("/campaigns/%s", cc.Campaign.ID) }
440+
data-entity-id={ entity.ID }
441+
></div>
442+
}
443+
433444
// blockDivider renders a visual separator between content sections.
434445
templ blockDivider() {
435446
<hr class="border-edge my-2"/>

static/js/widgets/shop_inventory.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,18 @@ Chronicle.register('shop_inventory', {
2424
var editable = el.dataset.editable === 'true';
2525
var csrfToken = el.dataset.csrfToken || '';
2626

27+
// Transaction endpoints derive from the campaign URL.
28+
var txEndpoint = campaignUrl + '/armory/transactions';
29+
var purchaseEndpoint = campaignUrl + '/armory/purchase';
30+
2731
// Internal state.
2832
var state = {
2933
items: [],
3034
loading: true,
3135
addMode: false,
36+
showTransactions: false,
37+
transactions: [],
38+
txLoading: false,
3239
searchQuery: '',
3340
searchResults: [],
3441
searchTimer: null,
@@ -91,6 +98,26 @@ Chronicle.register('shop_inventory', {
9198
'.shop-inv-action-btn.blue { background: #3b82f6; }',
9299
'.shop-inv-action-btn.blue:hover:not(:disabled) { background: #2563eb; }',
93100
'.shop-inv-label { font-size: 0.6875rem; color: #6b7280; font-weight: 500; }',
101+
'.shop-inv-tx-toggle { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 0.25rem; background: #f3f4f6; color: #6b7280; border: 1px solid #e5e7eb; cursor: pointer; margin-left: 0.375rem; }',
102+
'.dark .shop-inv-tx-toggle { background: #374151; color: #9ca3af; border-color: #4b5563; }',
103+
'.shop-inv-tx-toggle:hover { background: #e5e7eb; }',
104+
'.dark .shop-inv-tx-toggle:hover { background: #4b5563; }',
105+
'.shop-inv-tx-section { margin-top: 0.75rem; border-top: 1px solid #e5e7eb; padding-top: 0.75rem; }',
106+
'.dark .shop-inv-tx-section { border-color: #374151; }',
107+
'.shop-inv-tx-title { font-size: 0.75rem; font-weight: 600; color: #6b7280; margin-bottom: 0.5rem; }',
108+
'.shop-inv-tx-list { display: flex; flex-direction: column; gap: 0.25rem; }',
109+
'.shop-inv-tx-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0.375rem; font-size: 0.75rem; border-radius: 0.25rem; }',
110+
'.shop-inv-tx-row:nth-child(even) { background: #f9fafb; }',
111+
'.dark .shop-inv-tx-row:nth-child(even) { background: #1f2937; }',
112+
'.shop-inv-tx-type { font-weight: 500; text-transform: capitalize; min-width: 4rem; }',
113+
'.shop-inv-tx-type.purchase { color: #059669; }',
114+
'.shop-inv-tx-type.sale { color: #2563eb; }',
115+
'.shop-inv-tx-type.gift { color: #8b5cf6; }',
116+
'.shop-inv-tx-type.restock { color: #d97706; }',
117+
'.shop-inv-tx-detail { flex: 1; color: #4b5563; }',
118+
'.dark .shop-inv-tx-detail { color: #9ca3af; }',
119+
'.shop-inv-tx-price { font-weight: 500; color: #d97706; white-space: nowrap; }',
120+
'.shop-inv-tx-date { color: #9ca3af; font-size: 0.6875rem; white-space: nowrap; }',
94121
].join('\n');
95122
el.appendChild(style);
96123

@@ -152,9 +179,101 @@ Chronicle.register('shop_inventory', {
152179
wrapper.appendChild(list);
153180
}
154181

182+
// Transaction history section.
183+
if (editable) {
184+
var txToggle = document.createElement('button');
185+
txToggle.className = 'shop-inv-tx-toggle';
186+
txToggle.innerHTML = '<i class="fas fa-receipt"></i> ' + (state.showTransactions ? 'Hide Transactions' : 'Transactions');
187+
txToggle.onclick = function () {
188+
state.showTransactions = !state.showTransactions;
189+
if (state.showTransactions && state.transactions.length === 0) {
190+
loadTransactions();
191+
}
192+
render();
193+
};
194+
header.appendChild(txToggle);
195+
}
196+
197+
if (state.showTransactions) {
198+
wrapper.appendChild(renderTransactionSection());
199+
}
200+
155201
el.appendChild(wrapper);
156202
}
157203

204+
function renderTransactionSection() {
205+
var section = document.createElement('div');
206+
section.className = 'shop-inv-tx-section';
207+
208+
var title = document.createElement('div');
209+
title.className = 'shop-inv-tx-title';
210+
title.textContent = 'Recent Transactions';
211+
section.appendChild(title);
212+
213+
if (state.txLoading) {
214+
var loading = document.createElement('div');
215+
loading.className = 'shop-inv-empty';
216+
loading.textContent = 'Loading transactions...';
217+
section.appendChild(loading);
218+
} else if (state.transactions.length === 0) {
219+
var empty = document.createElement('div');
220+
empty.className = 'shop-inv-empty';
221+
empty.textContent = 'No transactions recorded.';
222+
section.appendChild(empty);
223+
} else {
224+
var list = document.createElement('div');
225+
list.className = 'shop-inv-tx-list';
226+
for (var i = 0; i < state.transactions.length; i++) {
227+
list.appendChild(renderTransactionRow(state.transactions[i]));
228+
}
229+
section.appendChild(list);
230+
}
231+
return section;
232+
}
233+
234+
function renderTransactionRow(tx) {
235+
var row = document.createElement('div');
236+
row.className = 'shop-inv-tx-row';
237+
238+
var typeSpan = document.createElement('span');
239+
typeSpan.className = 'shop-inv-tx-type ' + (tx.transaction_type || '');
240+
typeSpan.textContent = tx.transaction_type || 'unknown';
241+
row.appendChild(typeSpan);
242+
243+
var detail = document.createElement('span');
244+
detail.className = 'shop-inv-tx-detail';
245+
var parts = [];
246+
if (tx.item_name) parts.push(tx.item_name);
247+
if (tx.quantity > 1) parts.push('x' + tx.quantity);
248+
if (tx.buyer_name) parts.push('\u2192 ' + tx.buyer_name);
249+
detail.textContent = parts.join(' ');
250+
row.appendChild(detail);
251+
252+
if (tx.price_paid) {
253+
var price = document.createElement('span');
254+
price.className = 'shop-inv-tx-price';
255+
price.textContent = tx.price_paid;
256+
row.appendChild(price);
257+
}
258+
259+
var date = document.createElement('span');
260+
date.className = 'shop-inv-tx-date';
261+
date.textContent = formatTxDate(tx.created_at);
262+
row.appendChild(date);
263+
264+
return row;
265+
}
266+
267+
function formatTxDate(isoStr) {
268+
if (!isoStr) return '';
269+
try {
270+
var d = new Date(isoStr);
271+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
272+
} catch (e) {
273+
return '';
274+
}
275+
}
276+
158277
function renderItem(item) {
159278
var meta = item.metadata || {};
160279
var isCustom = !item.targetEntityID;
@@ -577,6 +696,35 @@ Chronicle.register('shop_inventory', {
577696
});
578697
}
579698

699+
// Load transaction history for this shop entity.
700+
function loadTransactions() {
701+
// Extract entity ID from the relations endpoint.
702+
// Format: /campaigns/:id/entities/:eid/relations
703+
var parts = relationsEndpoint.split('/');
704+
var eidIdx = parts.indexOf('entities');
705+
var entityId = eidIdx >= 0 ? parts[eidIdx + 1] : '';
706+
if (!entityId) return;
707+
708+
state.txLoading = true;
709+
render();
710+
711+
Chronicle.apiFetch(txEndpoint + '?shop=' + encodeURIComponent(entityId) + '&per_page=20', { method: 'GET' })
712+
.then(function (res) {
713+
if (!res.ok) throw new Error('Failed to load transactions');
714+
return res.json();
715+
})
716+
.then(function (data) {
717+
state.transactions = data.data || [];
718+
state.txLoading = false;
719+
render();
720+
})
721+
.catch(function (err) {
722+
console.error('Shop inventory: failed to load transactions', err);
723+
state.txLoading = false;
724+
render();
725+
});
726+
}
727+
580728
// Initial load.
581729
loadInventory();
582730
loadEntityTypes();

0 commit comments

Comments
 (0)