Tidy invite delivery choices and align email fields
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.' : 'We’ll send the invite. You get the link too.'}</small></span>
|
||||
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user