Tidy invite delivery choices and align email fields
Magent CI/CD / verify (push) Canceled after 1m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-06 22:38:17 +12:00
parent a32928b1c5
commit 212ac560ec
5 changed files with 92 additions and 26 deletions
+4 -24
View File
@@ -1,5 +1,7 @@
'use client'
import InviteDeliveryChoice from '../../ui/InviteDeliveryChoice'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
@@ -1872,29 +1874,7 @@ export default function AdminInviteManagementPage() {
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-delivery-grid">
<button
type="button"
className={inviteDeliveryMethod === 'manual' ? 'is-selected' : ''}
onClick={() => {
setInviteDeliveryMethod('manual')
setInviteForm((current) => ({ ...current, recipient_email: '', message: '' }))
}}
>
<span className="eyebrow">Manual</span>
<strong>Give me a link</strong>
<small>Magent creates the URL. You copy and share it yourself.</small>
</button>
<button
type="button"
className={inviteDeliveryMethod === 'email' ? 'is-selected' : ''}
onClick={() => setInviteDeliveryMethod('email')}
>
<span className="eyebrow">Email</span>
<strong>Send it for me</strong>
<small>Magent emails the invitation and still gives you a copyable URL.</small>
</button>
</div>
<InviteDeliveryChoice value={inviteDeliveryMethod} onChange={(method) => { setInviteDeliveryMethod(method); if (method === 'manual') setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }} />
{inviteDeliveryMethod === 'manual' && (
<div className="invite-delivery-summary">
@@ -1904,7 +1884,7 @@ export default function AdminInviteManagementPage() {
)}
{inviteDeliveryMethod === 'email' && (
<div className="invite-flow-field-grid">
<div className="invite-flow-field-grid invite-delivery-fields">
<label>
<span>Recipient email</span>
<input
+4 -2
View File
@@ -1,5 +1,7 @@
'use client'
import InviteDeliveryChoice from '../../ui/InviteDeliveryChoice'
import PageHeading from '../../ui/PageHeading'
import { useRouter } from 'next/navigation'
@@ -283,9 +285,9 @@ export default function ProfileInvitesPage() {
{flowStep >= 4 && <section className="invite-flow-step is-active">
<header><span className="invite-flow-number">04</span><div><span className="eyebrow">Delivery</span><h3>How will they receive it?</h3><p>Copy the link yourself, or let Magent email it directly.</p></div></header>
<div className="invite-flow-fields">
<div className="invite-delivery-grid"><button type="button" className={deliveryMethod === 'manual' ? 'is-selected' : ''} onClick={() => { setDeliveryMethod('manual'); setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }}><span className="eyebrow">Manual</span><strong>Give me a link</strong><small>Magent creates the URL. You copy and share it yourself.</small></button><button type="button" className={deliveryMethod === 'email' ? 'is-selected' : ''} onClick={() => setDeliveryMethod('email')}><span className="eyebrow">Email</span><strong>Send it for me</strong><small>Magent emails the invitation and still gives you a copyable URL.</small></button></div>
<InviteDeliveryChoice value={deliveryMethod} onChange={(method) => { setDeliveryMethod(method); if (method === 'manual') setInviteForm((current) => ({ ...current, recipient_email: '', message: '' })) }} />
{deliveryMethod === 'manual' && <div className="invite-delivery-summary"><strong>Your link will appear as soon as the invite is created.</strong><span>No email address is required and Magent will not send a message.</span></div>}
{deliveryMethod === 'email' && <div className="invite-flow-field-grid"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
{deliveryMethod === 'email' && <div className="invite-flow-field-grid invite-delivery-fields"><label><span>Recipient email</span><input type="email" value={inviteForm.recipient_email} onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))} placeholder="person@example.com" /></label><label><span>Email note (optional)</span><textarea rows={3} value={inviteForm.message} onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))} placeholder="A short personal message" /></label></div>}
{editingId != null && <label className="invite-status-control"><input type="checkbox" checked={inviteForm.enabled} onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))} /><span><strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong><small>Disable this existing invite to stop its link from accepting sign-ups.</small></span></label>}
<div className="invite-flow-actions"><button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>Back</button><button type="submit" disabled={saving || !deliveryMethod || (deliveryMethod === 'email' && !isValidEmail(inviteForm.recipient_email))}>{saving ? 'Saving…' : editingId != null ? 'Save invite' : deliveryMethod === 'email' ? 'Create and email invite' : 'Create invite link'}</button></div>
</div>
+17
View File
@@ -0,0 +1,17 @@
'use client'
import './invite-delivery.css'
export default function InviteDeliveryChoice({ value, onChange }: {
value: 'manual' | 'email' | '' | null; onChange: (method: 'manual' | 'email') => void
}) {
return <div className="invite-delivery-options" role="group" aria-label="Invite delivery method">
{(['manual', 'email'] as const).map((method) => <button key={method} type="button" aria-pressed={value === method} onClick={() => onChange(method)}>
<span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
{method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>}
</svg></span>
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'Share it yourself. No email needed.' : 'Well send the invite. You get the link too.'}</small></span>
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
</button>)}
</div>
}
+15
View File
@@ -0,0 +1,15 @@
.invite-delivery-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.page .invite-delivery-options > button { display: flex; align-items: center; justify-content: flex-start; gap: 14px; min-height: 96px; padding: 18px; text-align: left; border-radius: 12px; background: var(--ops-panel-2, #202023) !important; border: 1px solid var(--ops-line, #444) !important; color: var(--ops-text, #eee) !important; text-transform: none; }
.page .invite-delivery-options > button[aria-pressed='true'] { background: #302d3e !important; border-color: #c7bdff !important; box-shadow: inset 0 0 0 1px #c7bdff; }
.invite-delivery-options > button:focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
.invite-delivery-options .delivery-choice-icon { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 42px; border-radius: 10px; background: #ffffff09; color: #c7bdff; }
.delivery-choice-icon svg { width: 24px; height: 24px; }
.invite-delivery-options .delivery-choice-copy { display: grid; gap: 6px; min-width: 0; flex: 1; }
.delivery-choice-copy strong { font-size: .95rem; color: var(--ops-text, #eee); }
.delivery-choice-copy small { font-size: .8rem; font-weight: 400; line-height: 1.45; color: var(--ops-muted, #bbb); }
.invite-delivery-options .delivery-choice-check { flex: 0 0 20px; width: 20px; height: 20px; border: 1px solid var(--ops-line, #666); border-radius: 50%; display: grid; place-items: center; color: #c7bdff; font-size: .8rem; }
.invite-delivery-fields { align-items: start; }
.invite-delivery-fields > label { display: grid; align-content: start; gap: 8px; }
.invite-delivery-fields input { min-height: 48px; }
.invite-delivery-fields textarea { min-height: 88px; resize: vertical; }
@media (max-width: 640px) { .invite-delivery-options { grid-template-columns: 1fr; } }
+52
View File
@@ -0,0 +1,52 @@
// All APIs are mocked. This check creates no real invites or emails.
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
await context.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const user = { username: 'member', role: 'user', invite_management_enabled: true }
let json = { items: [], requests: [], services: [] }
if (path.endsWith('/auth/me')) json = user
if (path.endsWith('/auth/profile')) json = { user }
if (path.endsWith('/auth/profile/invites')) json = { invites: [], invite_access: { enabled: true } }
return route.fulfill({ status: 200, json })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', e => errors.push(e.message))
await page.goto(base + '/profile/invites')
await page.getByLabel('Invite name', { exact: true }).fill('Family')
await page.getByRole('button', { name: 'Continue to description' }).click()
await page.getByRole('button', { name: 'Skip', exact: true }).click()
await page.getByRole('button', { name: 'Continue to delivery' }).click()
const email = page.getByRole('button', { name: /Send an email/ })
const manual = page.getByRole('button', { name: /Copy a link/ })
await email.click()
assert.equal(await email.getAttribute('aria-pressed'), 'true')
assert.equal(await manual.getAttribute('aria-pressed'), 'false')
assert.equal(await page.locator('.delivery-choice-icon svg').count(), 2)
const recipient = page.getByLabel('Recipient email', { exact: true })
const note = page.getByLabel('Email note (optional)', { exact: true })
const a = await recipient.boundingBox(), b = await note.boundingBox()
assert.ok(Math.abs(a.y - b.y) < 2, 'Email fields must align at the top')
await recipient.fill('friend@example.com')
assert.equal(await page.getByRole('button', { name: 'Create and email invite' }).isEnabled(), true)
if (process.env.REVIEW_DIR) await page.screenshot({ path: process.env.REVIEW_DIR + '/invite-delivery-desktop.png', fullPage: true })
await manual.click()
assert.equal(await recipient.count(), 0)
assert.equal(await page.getByRole('button', { name: 'Create invite link' }).isEnabled(), true)
await page.setViewportSize({ width: 390, height: 844 })
await email.click()
const m = await manual.boundingBox(), e = await email.boundingBox()
assert.ok(e.y >= m.y + m.height, 'Mobile choices should stack')
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'No horizontal overflow')
if (process.env.REVIEW_DIR) await page.screenshot({ path: process.env.REVIEW_DIR + '/invite-delivery-mobile.png', fullPage: true })
assert.deepEqual(errors, [])
console.log('Invite delivery: desktop alignment, icons, selection, manual/email visibility and mobile layout passed.')
} finally { await browser.close() }
})().catch(e => { console.error(e); process.exitCode = 1 })