wiki-plugin-shoppe 0.0.1 β†’ 0.0.3

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/client/client.js CHANGED
@@ -4,93 +4,177 @@
4
4
  emit: function($item, item) {
5
5
  const div = $item[0];
6
6
  div.innerHTML = `
7
- <div class="shoppe-widget">
7
+ <div class="sw">
8
8
  <style>
9
- .shoppe-widget { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 700px; margin: 0 auto; }
10
- .shoppe-drop { border: 2px dashed #ccc; border-radius: 14px; padding: 40px 24px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; background: #fafafa; }
11
- .shoppe-drop.dragover { border-color: #0066cc; background: #e8f0fe; }
12
- .shoppe-drop-icon { font-size: 48px; margin-bottom: 12px; }
13
- .shoppe-drop h3 { font-size: 18px; font-weight: 600; margin-bottom: 6px; color: #1d1d1f; }
14
- .shoppe-drop p { font-size: 13px; color: #888; margin-bottom: 16px; }
15
- .shoppe-btn { display: inline-block; padding: 10px 22px; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: background 0.15s; }
16
- .shoppe-btn-blue { background: #0066cc; color: white; }
17
- .shoppe-btn-blue:hover { background: #0055aa; }
18
- .shoppe-btn-green { background: #10b981; color: white; }
19
- .shoppe-btn-green:hover { background: #059669; }
20
- .shoppe-section { margin-top: 20px; }
21
- .shoppe-section h4 { font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
22
- .shoppe-tenant { display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #eee; border-radius: 10px; padding: 12px 16px; margin-bottom: 8px; }
23
- .shoppe-tenant-info { display: flex; flex-direction: column; gap: 2px; }
24
- .shoppe-tenant-name { font-weight: 600; font-size: 14px; }
25
- .shoppe-tenant-code { font-size: 16px; letter-spacing: 3px; }
26
- .shoppe-tenant-link { font-size: 13px; color: #0066cc; text-decoration: none; }
27
- .shoppe-tenant-link:hover { text-decoration: underline; }
28
- .shoppe-status { margin-top: 14px; border-radius: 10px; padding: 14px 18px; font-size: 14px; }
29
- .shoppe-status.info { background: #e8f0fe; color: #1a56db; }
30
- .shoppe-status.success { background: #d1fae5; color: #065f46; }
31
- .shoppe-status.error { background: #fee2e2; color: #991b1b; }
32
- .shoppe-register { display: flex; gap: 8px; margin-top: 12px; }
33
- .shoppe-register input { flex: 1; padding: 9px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
34
- .shoppe-result-counts { margin-top: 8px; font-size: 13px; opacity: 0.8; }
9
+ .sw { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 680px; margin: 0 auto; color: #1d1d1f; }
10
+ .sw h2 { font-size: 22px; font-weight: 700; margin: 0 0 4px; }
11
+ .sw h3 { font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 10px; }
12
+ .sw-section { margin-bottom: 24px; }
13
+ .sw-card { background: #f5f5f7; border-radius: 12px; padding: 18px 20px; margin-bottom: 10px; }
14
+ .sw-step { display: flex; gap: 14px; align-items: flex-start; margin-bottom: 12px; }
15
+ .sw-step:last-child { margin-bottom: 0; }
16
+ .sw-step-num { background: #0066cc; color: white; border-radius: 50%; width: 24px; height: 24px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
17
+ .sw-step-body { font-size: 14px; line-height: 1.5; color: #333; }
18
+ .sw-step-body strong { color: #1d1d1f; }
19
+ .sw-step-body code { background: #e8e8ed; border-radius: 4px; padding: 1px 5px; font-size: 12px; }
20
+ .sw-tree { font-family: monospace; font-size: 12px; background: #1d1d1f; color: #a8f0a8; border-radius: 8px; padding: 14px 16px; line-height: 1.7; white-space: pre; overflow-x: auto; margin-top: 8px; }
21
+ .sw-shoppe { display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #e5e5ea; border-radius: 10px; padding: 12px 16px; margin-bottom: 8px; }
22
+ .sw-shoppe-left { display: flex; flex-direction: column; gap: 2px; }
23
+ .sw-shoppe-name { font-weight: 600; font-size: 15px; }
24
+ .sw-shoppe-code { font-size: 18px; letter-spacing: 4px; }
25
+ .sw-link { font-size: 13px; color: #0066cc; text-decoration: none; white-space: nowrap; }
26
+ .sw-link:hover { text-decoration: underline; }
27
+ .sw-empty { font-size: 13px; color: #999; font-style: italic; }
28
+ .sw-drop { border: 2px dashed #ccc; border-radius: 12px; padding: 28px 20px; text-align: center; background: #fafafa; transition: border-color 0.2s, background 0.2s; cursor: pointer; }
29
+ .sw-drop.dragover { border-color: #0066cc; background: #e8f0fe; }
30
+ .sw-drop p { font-size: 13px; color: #888; margin: 6px 0 14px; }
31
+ .sw-btn { display: inline-block; padding: 9px 20px; border-radius: 18px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: background 0.15s; }
32
+ .sw-btn-blue { background: #0066cc; color: white; }
33
+ .sw-btn-blue:hover { background: #0055aa; }
34
+ .sw-btn-green { background: #10b981; color: white; }
35
+ .sw-btn-green:hover { background: #059669; }
36
+ .sw-register { display: flex; gap: 8px; margin-top: 10px; }
37
+ .sw-register input { flex: 1; padding: 9px 13px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; outline: none; }
38
+ .sw-register input:focus { border-color: #0066cc; }
39
+ .sw-status { margin-top: 12px; border-radius: 10px; padding: 13px 16px; font-size: 14px; line-height: 1.5; display: none; }
40
+ .sw-status.info { background: #e8f0fe; color: #1a56db; display: block; }
41
+ .sw-status.success { background: #d1fae5; color: #065f46; display: block; }
42
+ .sw-status.error { background: #fee2e2; color: #991b1b; display: block; }
43
+ .sw-status code { background: rgba(0,0,0,0.08); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
35
44
  </style>
36
45
 
37
- <!-- Drop zone -->
38
- <div class="shoppe-drop" id="shoppe-drop">
39
- <div class="shoppe-drop-icon">πŸ›οΈ</div>
40
- <h3>Drop your shoppe archive here</h3>
41
- <p>Drag and drop a .zip archive, or click to browse.<br>Archive must contain manifest.json with your uuid and emojicode.</p>
42
- <button class="shoppe-btn shoppe-btn-blue" id="shoppe-browse-btn">Choose Archive</button>
43
- <input type="file" id="shoppe-file-input" accept=".zip" style="display:none">
46
+ <!-- Directory -->
47
+ <div class="sw-section">
48
+ <h2>πŸ›οΈ Shoppe</h2>
49
+ <p style="font-size:14px;color:#555;margin:4px 0 16px">A multi-tenant digital goods marketplace. Browse the shoppes below, or open one of your own.</p>
50
+ <h3>Shoppes on this server</h3>
51
+ <div id="sw-directory"><em class="sw-empty">Loading...</em></div>
44
52
  </div>
45
53
 
46
- <!-- Register section -->
47
- <div class="shoppe-section">
48
- <h4>Register New Shoppe</h4>
49
- <div class="shoppe-register">
50
- <input type="text" id="shoppe-name-input" placeholder="Shoppe name (e.g. Zach's Art Store)">
51
- <button class="shoppe-btn shoppe-btn-green" id="shoppe-register-btn">Register</button>
54
+ <!-- How to join -->
55
+ <div class="sw-section">
56
+ <h3>How to open a shoppe</h3>
57
+ <div class="sw-card">
58
+ <div class="sw-step">
59
+ <div class="sw-step-num">1</div>
60
+ <div class="sw-step-body"><strong>Ask the wiki owner to register you.</strong> They'll use the form at the bottom of this page and give you a <code>uuid</code> and <code>emojicode</code> β€” your shoppe's identity.</div>
61
+ </div>
62
+ <div class="sw-step">
63
+ <div class="sw-step-num">2</div>
64
+ <div class="sw-step-body"><strong>Build your shoppe folder</strong> with this structure, then zip the whole thing:
65
+ <div class="sw-tree">my-shoppe.zip
66
+ manifest.json ← { "uuid": "…", "emojicode": "…", "name": "My Shoppe" }
67
+ books/ ← .epub .pdf .mobi
68
+ music/
69
+ My Album/ ← subfolder = album (add cover.jpg inside)
70
+ cover.jpg
71
+ 01-track.mp3
72
+ standalone.mp3 ← file directly here = single track
73
+ posts/ ← .md files
74
+ albums/
75
+ Vacation 2025/ ← subfolder of images = photo album
76
+ photo1.jpg
77
+ products/
78
+ T-Shirt/ ← subfolder = physical product
79
+ cover.jpg
80
+ info.json ← { "title": "…", "description": "…", "price": 25, "shipping": 5 }</div>
81
+ </div>
82
+ </div>
83
+ <div class="sw-step">
84
+ <div class="sw-step-num">3</div>
85
+ <div class="sw-step-body"><strong>Drag your .zip onto the upload zone below.</strong> Your goods will be registered and your shoppe will go live immediately.</div>
86
+ </div>
87
+ <div class="sw-step">
88
+ <div class="sw-step-num">4</div>
89
+ <div class="sw-step-body"><strong>To update your shoppe</strong>, just rebuild your folder and upload a new archive β€” existing items will be overwritten and new ones added.</div>
90
+ </div>
52
91
  </div>
53
92
  </div>
54
93
 
55
- <!-- Tenant list -->
56
- <div class="shoppe-section" id="shoppe-tenants-section">
57
- <h4>Active Shoppes</h4>
58
- <div id="shoppe-tenants-list"><em style="font-size:13px;color:#999">Loading...</em></div>
94
+ <!-- Upload -->
95
+ <div class="sw-section">
96
+ <h3>Upload your archive</h3>
97
+ <div class="sw-drop" id="sw-drop">
98
+ <div style="font-size:40px">πŸ“¦</div>
99
+ <p>Drag and drop your .zip here, or click to browse.<br>Your <code>manifest.json</code> must contain the <code>uuid</code> and <code>emojicode</code> you were given.</p>
100
+ <button class="sw-btn sw-btn-blue" id="sw-browse-btn">Choose Archive</button>
101
+ <input type="file" id="sw-file-input" accept=".zip" style="display:none">
102
+ </div>
103
+ <div id="sw-upload-status" class="sw-status"></div>
104
+ </div>
105
+
106
+ <!-- Owner: register -->
107
+ <div class="sw-section" id="sw-owner-section" style="display:none">
108
+ <h3>Register a new shoppe (owner only)</h3>
109
+ <div class="sw-register">
110
+ <input type="text" id="sw-name-input" placeholder="Shoppe name (e.g. Zach's Art Store)">
111
+ <button class="sw-btn sw-btn-green" id="sw-register-btn">Register</button>
112
+ </div>
113
+ <div id="sw-register-status" class="sw-status"></div>
59
114
  </div>
60
115
 
61
- <!-- Status -->
62
- <div id="shoppe-status" style="display:none"></div>
63
116
  </div>
64
117
  `;
65
118
 
66
- setupShoppeListeners(div);
67
- loadTenants(div);
119
+ setupListeners(div);
120
+ loadDirectory(div);
121
+ checkOwner(div);
68
122
  },
69
123
 
70
124
  bind: function($item, item) {}
71
125
  };
72
126
 
73
- function setupShoppeListeners(container) {
74
- const drop = container.querySelector('#shoppe-drop');
75
- const fileInput = container.querySelector('#shoppe-file-input');
76
- const browseBtn = container.querySelector('#shoppe-browse-btn');
77
- const registerBtn = container.querySelector('#shoppe-register-btn');
78
- const nameInput = container.querySelector('#shoppe-name-input');
127
+ // ── Directory (public) ──────────────────────────────────────────────────────
79
128
 
80
- // Browse button
81
- browseBtn.addEventListener('click', () => fileInput.click());
129
+ async function loadDirectory(container) {
130
+ const el = container.querySelector('#sw-directory');
131
+ try {
132
+ const resp = await fetch('/plugin/shoppe/directory');
133
+ const result = await resp.json();
134
+ if (!result.success || result.shoppes.length === 0) {
135
+ el.innerHTML = '<em class="sw-empty">No shoppes yet β€” be the first!</em>';
136
+ return;
137
+ }
138
+ el.innerHTML = result.shoppes.map(s => `
139
+ <div class="sw-shoppe">
140
+ <div class="sw-shoppe-left">
141
+ <span class="sw-shoppe-name">${s.name}</span>
142
+ <span class="sw-shoppe-code">${s.emojicode}</span>
143
+ </div>
144
+ <a class="sw-link" href="${s.url}" target="_blank">Visit shoppe β†’</a>
145
+ </div>
146
+ `).join('');
147
+ } catch (err) {
148
+ el.innerHTML = '<em class="sw-empty">Could not load directory.</em>';
149
+ }
150
+ }
151
+
152
+ // ── Owner check ─────────────────────────────────────────────────────────────
153
+
154
+ async function checkOwner(container) {
155
+ try {
156
+ const resp = await fetch('/plugin/shoppe/tenants');
157
+ if (resp.ok) {
158
+ container.querySelector('#sw-owner-section').style.display = 'block';
159
+ }
160
+ } catch (err) { /* not owner, stay hidden */ }
161
+ }
82
162
 
83
- // File selected via browser
163
+ // ── Listeners ───────────────────────────────────────────────────────────────
164
+
165
+ function setupListeners(container) {
166
+ const drop = container.querySelector('#sw-drop');
167
+ const fileInput = container.querySelector('#sw-file-input');
168
+ const browseBtn = container.querySelector('#sw-browse-btn');
169
+ const registerBtn = container.querySelector('#sw-register-btn');
170
+ const nameInput = container.querySelector('#sw-name-input');
171
+
172
+ browseBtn.addEventListener('click', () => fileInput.click());
84
173
  fileInput.addEventListener('change', e => {
85
- const file = e.target.files[0];
86
- if (file) uploadArchive(file, container);
174
+ if (e.target.files[0]) uploadArchive(e.target.files[0], container);
87
175
  });
88
176
 
89
- // Drag and drop
90
- drop.addEventListener('dragover', e => {
91
- e.preventDefault();
92
- drop.classList.add('dragover');
93
- });
177
+ drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dragover'); });
94
178
  drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
95
179
  drop.addEventListener('drop', e => {
96
180
  e.preventDefault();
@@ -99,109 +183,85 @@
99
183
  if (file && file.name.endsWith('.zip')) {
100
184
  uploadArchive(file, container);
101
185
  } else {
102
- showStatus(container, 'Please drop a .zip archive', 'error');
186
+ showStatus(container, '#sw-upload-status', 'Please drop a .zip archive', 'error');
103
187
  }
104
188
  });
105
189
 
106
- // Register
107
- registerBtn.addEventListener('click', async () => {
108
- const name = nameInput.value.trim();
109
- if (!name) { showStatus(container, 'Enter a shoppe name first', 'error'); return; }
110
- registerBtn.disabled = true;
111
- registerBtn.textContent = 'Registering...';
112
- try {
113
- const resp = await fetch('/plugin/shoppe/register', {
114
- method: 'POST',
115
- headers: { 'Content-Type': 'application/json' },
116
- body: JSON.stringify({ name })
117
- });
118
- const result = await resp.json();
119
- if (result.success) {
120
- nameInput.value = '';
121
- showStatus(container,
122
- `βœ… Registered! UUID: <code>${result.tenant.uuid}</code><br>Emojicode: <strong>${result.tenant.emojicode}</strong><br>Put these in your manifest.json`,
123
- 'success');
124
- loadTenants(container);
125
- } else {
126
- throw new Error(result.error || 'Registration failed');
127
- }
128
- } catch (err) {
129
- showStatus(container, `❌ ${err.message}`, 'error');
130
- } finally {
131
- registerBtn.disabled = false;
132
- registerBtn.textContent = 'Register';
133
- }
134
- });
190
+ if (registerBtn) {
191
+ registerBtn.addEventListener('click', () => registerShoppe(container));
192
+ }
135
193
  }
136
194
 
137
- async function uploadArchive(file, container) {
138
- showStatus(container, `⏳ Uploading and processing <strong>${file.name}</strong>...`, 'info');
195
+ // ── Upload ──────────────────────────────────────────────────────────────────
139
196
 
197
+ async function uploadArchive(file, container) {
198
+ showStatus(container, '#sw-upload-status', `⏳ Uploading <strong>${file.name}</strong>…`, 'info');
140
199
  const form = new FormData();
141
200
  form.append('archive', file);
142
-
143
201
  try {
144
202
  const resp = await fetch('/plugin/shoppe/upload', { method: 'POST', body: form });
145
203
  const result = await resp.json();
204
+ if (!result.success) throw new Error(result.error || 'Upload failed');
146
205
 
147
- if (result.success) {
148
- const r = result.results;
149
- const counts = [
150
- r.books.length && `πŸ“š ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
151
- r.music.length && `🎡 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
152
- r.posts.length && `πŸ“ ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
153
- r.albums.length && `πŸ–ΌοΈ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
154
- r.products.length && `πŸ“¦ ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`
155
- ].filter(Boolean).join(', ') || 'nothing uploaded';
156
-
157
- showStatus(container,
158
- `βœ… Uploaded for <strong>${result.tenant.name}</strong> ${result.tenant.emojicode}<br>
159
- <div class="shoppe-result-counts">${counts}</div>
160
- <a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" style="display:inline-block;margin-top:10px;color:#0066cc;">View shoppe β†’</a>`,
161
- 'success');
162
- loadTenants(container);
163
- } else {
164
- throw new Error(result.error || 'Upload failed');
165
- }
206
+ const r = result.results;
207
+ const counts = [
208
+ r.books.length && `πŸ“š ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
209
+ r.music.length && `🎡 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
210
+ r.posts.length && `πŸ“ ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
211
+ r.albums.length && `πŸ–ΌοΈ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
212
+ r.products.length && `πŸ“¦ ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`
213
+ ].filter(Boolean).join(' Β· ') || 'no items found';
214
+
215
+ showStatus(container, '#sw-upload-status',
216
+ `βœ… <strong>${result.tenant.name}</strong> ${result.tenant.emojicode} updated β€” ${counts}<br>
217
+ <a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" class="sw-link" style="display:inline-block;margin-top:8px;">View your shoppe β†’</a>`,
218
+ 'success');
219
+ loadDirectory(container);
166
220
  } catch (err) {
167
- showStatus(container, `❌ ${err.message}`, 'error');
221
+ showStatus(container, '#sw-upload-status', `❌ ${err.message}`, 'error');
168
222
  }
169
223
  }
170
224
 
171
- async function loadTenants(container) {
172
- const list = container.querySelector('#shoppe-tenants-list');
173
- if (!list) return;
225
+ // ── Register (owner) ────────────────────────────────────────────────────────
226
+
227
+ async function registerShoppe(container) {
228
+ const nameInput = container.querySelector('#sw-name-input');
229
+ const registerBtn = container.querySelector('#sw-register-btn');
230
+ const name = nameInput.value.trim();
231
+ if (!name) { showStatus(container, '#sw-register-status', 'Enter a shoppe name first', 'error'); return; }
232
+
233
+ registerBtn.disabled = true;
234
+ registerBtn.textContent = 'Registering…';
174
235
  try {
175
- const resp = await fetch('/plugin/shoppe/tenants');
176
- if (!resp.ok) {
177
- list.innerHTML = '<em style="font-size:13px;color:#999">Not available (owner only)</em>';
178
- return;
179
- }
236
+ const resp = await fetch('/plugin/shoppe/register', {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ name })
240
+ });
180
241
  const result = await resp.json();
181
- if (!result.success || result.tenants.length === 0) {
182
- list.innerHTML = '<em style="font-size:13px;color:#999">No shoppes registered yet.</em>';
183
- return;
184
- }
185
- list.innerHTML = result.tenants.map(t => `
186
- <div class="shoppe-tenant">
187
- <div class="shoppe-tenant-info">
188
- <span class="shoppe-tenant-name">${t.name}</span>
189
- <span class="shoppe-tenant-code">${t.emojicode}</span>
190
- <code style="font-size:11px;color:#888">${t.uuid}</code>
191
- </div>
192
- <a class="shoppe-tenant-link" href="${t.url}" target="_blank">View shoppe β†’</a>
193
- </div>
194
- `).join('');
242
+ if (!result.success) throw new Error(result.error || 'Registration failed');
243
+
244
+ nameInput.value = '';
245
+ showStatus(container, '#sw-register-status',
246
+ `βœ… Registered! Give these to the shoppe owner:<br>
247
+ UUID: <code>${result.tenant.uuid}</code><br>
248
+ Emojicode: <strong>${result.tenant.emojicode}</strong>`,
249
+ 'success');
250
+ loadDirectory(container);
195
251
  } catch (err) {
196
- list.innerHTML = '<em style="font-size:13px;color:#999">Could not load tenants.</em>';
252
+ showStatus(container, '#sw-register-status', `❌ ${err.message}`, 'error');
253
+ } finally {
254
+ registerBtn.disabled = false;
255
+ registerBtn.textContent = 'Register';
197
256
  }
198
257
  }
199
258
 
200
- function showStatus(container, html, type) {
201
- const el = container.querySelector('#shoppe-status');
202
- el.className = `shoppe-status ${type}`;
259
+ // ── Helpers ─────────────────────────────────────────────────────────────────
260
+
261
+ function showStatus(container, selector, html, type) {
262
+ const el = container.querySelector(selector);
263
+ el.className = `sw-status ${type}`;
203
264
  el.innerHTML = html;
204
- el.style.display = 'block';
205
265
  }
206
266
 
207
267
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
@@ -22,6 +22,6 @@
22
22
  "form-data": "^4.0.0",
23
23
  "multer": "^1.4.5-lts.1",
24
24
  "node-fetch": "^2.6.1",
25
- "sessionless-node": "^0.9.12"
25
+ "sessionless-node": "latest"
26
26
  }
27
27
  }
package/server/server.js CHANGED
@@ -557,7 +557,7 @@ async function startServer(params) {
557
557
  }
558
558
  });
559
559
 
560
- // List all tenants (owner only)
560
+ // List all tenants (owner only β€” includes uuid for management)
561
561
  app.get('/plugin/shoppe/tenants', owner, (req, res) => {
562
562
  const tenants = loadTenants();
563
563
  const safe = Object.values(tenants).map(({ uuid, emojicode, name, createdAt }) => ({
@@ -567,6 +567,16 @@ async function startServer(params) {
567
567
  res.json({ success: true, tenants: safe });
568
568
  });
569
569
 
570
+ // Public directory β€” name, emojicode, and shoppe URL only
571
+ app.get('/plugin/shoppe/directory', (req, res) => {
572
+ const tenants = loadTenants();
573
+ const listing = Object.values(tenants).map(({ uuid, emojicode, name }) => ({
574
+ name, emojicode,
575
+ url: `/plugin/shoppe/${uuid}`
576
+ }));
577
+ res.json({ success: true, shoppes: listing });
578
+ });
579
+
570
580
  // Upload goods archive (auth via manifest uuid+emojicode)
571
581
  app.post('/plugin/shoppe/upload', upload.single('archive'), async (req, res) => {
572
582
  try {