fix: add org — domain no longer required, dropdown hides on blur, member bubbles UX

This commit is contained in:
James 2026-03-12 00:37:40 -04:00
parent 6e50974faf
commit 5c17bf5980
3 changed files with 118 additions and 47 deletions

View File

@ -1299,11 +1299,8 @@ func (h *Handlers) CreateOrg(w http.ResponseWriter, r *http.Request) {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Organization name required") ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Organization name required")
return return
} }
if len(req.Domains) == 0 { // Domains are optional — used for invite validation only
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required") // Validate domains are not empty strings (if provided)
return
}
// Validate domains are not empty strings
for _, d := range req.Domains { for _, d := range req.Domains {
if strings.TrimSpace(d) == "" { if strings.TrimSpace(d) == "" {
ErrorResponse(w, http.StatusBadRequest, "invalid_domains", "Empty domain not allowed") ErrorResponse(w, http.StatusBadRequest, "invalid_domains", "Empty domain not allowed")

BIN
dealspace

Binary file not shown.

View File

@ -162,7 +162,7 @@
<div class="relative"> <div class="relative">
<label class="block text-sm font-medium text-[#94a3b8] mb-2">Organization Name</label> <label class="block text-sm font-medium text-[#94a3b8] mb-2">Organization Name</label>
<input type="text" id="addOrgName" placeholder="Start typing to search or create..." autocomplete="off" <input type="text" id="addOrgName" placeholder="Start typing to search or create..." autocomplete="off"
oninput="orgNameSearch(this.value)" oninput="orgNameSearch(this.value)" onblur="orgNameBlur()"
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] text-sm"> class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] text-sm">
<div id="orgNameDropdown" class="hidden absolute left-0 right-0 top-full mt-1 bg-[#0d1f3c] border border-white/[0.12] rounded-lg z-10 max-h-48 overflow-y-auto shadow-xl"></div> <div id="orgNameDropdown" class="hidden absolute left-0 right-0 top-full mt-1 bg-[#0d1f3c] border border-white/[0.12] rounded-lg z-10 max-h-48 overflow-y-auto shadow-xl"></div>
<input type="hidden" id="addOrgId" value=""> <input type="hidden" id="addOrgId" value="">
@ -862,17 +862,16 @@
// ---- Add Org Modal ---- // ---- Add Org Modal ----
let orgSearchTimeout; let orgSearchTimeout;
let memberCount = 0; let pendingMembers = []; // array of {name, email, title}
function openAddOrgModal() { function openAddOrgModal() {
document.getElementById('addOrgModal').classList.remove('hidden'); document.getElementById('addOrgModal').classList.remove('hidden');
document.getElementById('addOrgError').classList.add('hidden'); document.getElementById('addOrgError').classList.add('hidden');
document.getElementById('addOrgName').value = ''; document.getElementById('addOrgName').value = '';
document.getElementById('addOrgId').value = ''; document.getElementById('addOrgId').value = '';
document.getElementById('memberRows').innerHTML = ''; pendingMembers = [];
memberCount = 0; renderMemberBubbles();
setOrgType('buyer', document.querySelector('[data-type="buyer"]')); setOrgType('buyer', document.querySelector('[data-type="buyer"]'));
addMemberRow();
loadGlobalOrgs(); loadGlobalOrgs();
} }
@ -904,8 +903,7 @@
function orgNameSearch(q) { function orgNameSearch(q) {
clearTimeout(orgSearchTimeout); clearTimeout(orgSearchTimeout);
const dd = document.getElementById('orgNameDropdown'); const dd = document.getElementById('orgNameDropdown');
const orgId = document.getElementById('addOrgId'); document.getElementById('addOrgId').value = '';
orgId.value = '';
if (!q) { dd.classList.add('hidden'); return; } if (!q) { dd.classList.add('hidden'); return; }
orgSearchTimeout = setTimeout(() => { orgSearchTimeout = setTimeout(() => {
const matches = allGlobalOrgs.filter(o => { const matches = allGlobalOrgs.filter(o => {
@ -913,43 +911,122 @@
return (d.name || '').toLowerCase().includes(q.toLowerCase()); return (d.name || '').toLowerCase().includes(q.toLowerCase());
}); });
if (!matches.length) { if (!matches.length) {
dd.innerHTML = `<div class="px-4 py-3 text-sm text-[#94a3b8]">No match — will create "<strong class="text-white">${escHtml(q)}</strong>" as new org</div>`; // No match — just hide, the typed name will be used as-is on submit
} else { dd.classList.add('hidden');
dd.innerHTML = matches.map(o => { return;
const d = parseData(o.data_text);
const name = d.name || 'Unnamed';
return `<div onclick="selectOrg('${o.entry_id}','${escHtml(name).replace(/'/g,"\'")}','${d.role||''}',this)"
class="px-4 py-3 text-sm text-white hover:bg-white/[0.06] cursor-pointer flex items-center gap-3">
<span class="font-medium">${escHtml(name)}</span>
<span class="text-xs text-[#475569] capitalize">${d.role || ''}</span>
</div>`;
}).join('');
} }
dd.innerHTML = matches.map(o => {
const d = parseData(o.data_text);
const name = d.name || 'Unnamed';
return `<div onclick="selectOrg('${o.entry_id}','${name.replace(/'/g,"\'")}','${d.role||''}')"
class="px-4 py-3 text-sm text-white hover:bg-white/[0.06] cursor-pointer flex items-center gap-3">
<span class="font-medium">${escHtml(name)}</span>
<span class="text-xs text-[#475569] capitalize">${d.role || ''}</span>
</div>`;
}).join('');
dd.classList.remove('hidden'); dd.classList.remove('hidden');
}, 200); }, 200);
} }
function selectOrg(orgId, orgName, role, el) { function orgNameBlur() {
// Hide dropdown shortly after blur (allow click to register first)
setTimeout(() => document.getElementById('orgNameDropdown').classList.add('hidden'), 200);
}
function selectOrg(orgId, orgName, role) {
document.getElementById('addOrgId').value = orgId; document.getElementById('addOrgId').value = orgId;
document.getElementById('addOrgName').value = orgName; document.getElementById('addOrgName').value = orgName;
if (role) setOrgType(role, document.querySelector(`[data-type="${role}"]`)); if (role) setOrgType(role, document.querySelector(`[data-type="${role}"]`));
document.getElementById('orgNameDropdown').classList.add('hidden'); document.getElementById('orgNameDropdown').classList.add('hidden');
// Load members of this org for suggestions
loadOrgMemberSuggestions(orgId);
} }
function addMemberRow() { let orgMemberSuggestions = [];
const idx = memberCount++; async function loadOrgMemberSuggestions(orgId) {
const row = document.createElement('div'); orgMemberSuggestions = [];
row.className = 'grid grid-cols-[1fr_1fr_1fr_auto] gap-2 items-center'; if (!orgId) return;
row.id = 'member-row-' + idx; // Get members from existing deal_org entries that reference this org
row.innerHTML = ` try {
<input type="text" placeholder="Name" id="mname-${idx}" const res = await fetchAPI('/api/orgs/' + orgId);
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]"> const org = await res.json();
<input type="email" placeholder="Email" id="memail-${idx}" if (org && org.members) orgMemberSuggestions = org.members;
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]"> } catch(e) {}
<input type="text" placeholder="Title" id="mtitle-${idx}" }
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<button onclick="document.getElementById('member-row-${idx}').remove()" class="text-[#475569] hover:text-red-400 transition text-lg leading-none">&times;</button>`; // ---- Member bubble system ----
document.getElementById('memberRows').appendChild(row); function renderMemberBubbles() {
const container = document.getElementById('memberRows');
const bubbles = pendingMembers.map((m, i) =>
`<div class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-[#c9a84c]/15 border border-[#c9a84c]/30 rounded-full text-sm text-white">
<span class="w-5 h-5 rounded-full bg-[#c9a84c]/30 flex items-center justify-center text-xs font-semibold text-[#c9a84c]">${(m.name||'?')[0].toUpperCase()}</span>
<span class="font-medium">${escHtml(m.name)}</span>
${m.title ? `<span class="text-[#94a3b8] text-xs">· ${escHtml(m.title)}</span>` : ''}
<button onclick="removeMember(${i})" class="text-[#94a3b8] hover:text-red-400 transition leading-none ml-1">&times;</button>
</div>`
).join('');
container.innerHTML = `
<div class="flex flex-wrap gap-2 mb-2">${bubbles}</div>
<div class="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 items-center" id="member-input-row">
<div class="relative">
<input type="text" id="new-mname" placeholder="Name" autocomplete="off"
oninput="memberNameSearch(this.value)"
class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<div id="memberNameDropdown" class="hidden absolute left-0 right-0 top-full mt-1 bg-[#0d1f3c] border border-white/[0.12] rounded-lg z-20 max-h-32 overflow-y-auto shadow-xl"></div>
</div>
<input type="email" id="new-memail" placeholder="Email"
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<input type="text" id="new-mtitle" placeholder="Title"
onkeydown="if(event.key==='Enter'){event.preventDefault();commitMember();}"
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<button onclick="commitMember()" class="w-7 h-7 rounded-full bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] text-lg font-bold flex items-center justify-center leading-none transition">+</button>
</div>`;
}
function memberNameSearch(q) {
const dd = document.getElementById('memberNameDropdown');
if (!q || !orgMemberSuggestions.length) { dd.classList.add('hidden'); return; }
const matches = orgMemberSuggestions.filter(m =>
(m.name || '').toLowerCase().includes(q.toLowerCase()) ||
(m.email || '').toLowerCase().includes(q.toLowerCase())
);
if (!matches.length) { dd.classList.add('hidden'); return; }
dd.innerHTML = matches.map(m =>
`<div onclick="selectMemberSuggestion(${JSON.stringify(JSON.stringify(m))})"
class="px-3 py-2 text-xs text-white hover:bg-white/[0.06] cursor-pointer">
<span class="font-medium">${escHtml(m.name)}</span>
${m.email ? `<span class="text-[#475569] ml-1">${escHtml(m.email)}</span>` : ''}
</div>`
).join('');
dd.classList.remove('hidden');
}
function selectMemberSuggestion(mJson) {
const m = JSON.parse(mJson);
document.getElementById('new-mname').value = m.name || '';
document.getElementById('new-memail').value = m.email || '';
document.getElementById('new-mtitle').value = m.title || '';
document.getElementById('memberNameDropdown').classList.add('hidden');
}
function commitMember() {
const name = (document.getElementById('new-mname')?.value || '').trim();
const email = (document.getElementById('new-memail')?.value || '').trim();
const title = (document.getElementById('new-mtitle')?.value || '').trim();
if (!name && !email) return;
pendingMembers.push({ name: name || email, email, title });
// Add to org suggestions for future use
if (!orgMemberSuggestions.find(m => m.email === email)) {
orgMemberSuggestions.push({ name, email, title });
}
renderMemberBubbles();
setTimeout(() => document.getElementById('new-mname')?.focus(), 50);
}
function removeMember(idx) {
pendingMembers.splice(idx, 1);
renderMemberBubbles();
} }
async function submitAddOrg() { async function submitAddOrg() {
@ -960,15 +1037,12 @@
const btn = document.getElementById('addOrgSubmitBtn'); const btn = document.getElementById('addOrgSubmitBtn');
if (!name) { errEl.textContent = 'Organization name is required'; errEl.classList.remove('hidden'); return; } if (!name) { errEl.textContent = 'Organization name is required'; errEl.classList.remove('hidden'); return; }
// Collect members // Collect members — commit any in-progress input row first
const members = []; const inProgName = document.getElementById('new-mname')?.value.trim();
document.querySelectorAll('#memberRows > div').forEach(row => { const inProgEmail = document.getElementById('new-memail')?.value.trim();
const idx = row.id.replace('member-row-', ''); const inProgTitle = document.getElementById('new-mtitle')?.value.trim();
const n = document.getElementById('mname-' + idx)?.value.trim(); if (inProgName || inProgEmail) pendingMembers.push({ name: inProgName || inProgEmail, email: inProgEmail, title: inProgTitle });
const e = document.getElementById('memail-' + idx)?.value.trim(); const members = [...pendingMembers];
const t = document.getElementById('mtitle-' + idx)?.value.trim();
if (n || e) members.push({ name: n || '', email: e || '', title: t || '' });
});
const perms = { const perms = {
download: document.getElementById('permDownload').value, download: document.getElementById('permDownload').value,