feat(admin): add offering pricing and payment/refund UI
- admin-offerings-routes.js: PATCH /api/admin/offerings/:id (allowlisted fields), GET /api/admin/registrations/:id/payments, POST /api/admin/payments/:id/refund - frontend: Pricing card on offering detail, Payments card (transaction history + record-refund action) on registration detail — admin/owner-gated, reusing existing admin UI conventions
This commit is contained in:
@@ -188,6 +188,12 @@ function OfferingDetail({ offeringId, canAuthor, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Pricing</h3>
|
||||
<p>{offering.price_amount === null ? <span className={styles.muted}>Free — no checkout required.</span> : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}</>}</p>
|
||||
{canAuthor && <SetPriceForm offeringId={offeringId} current={offering} onSaved={reload} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Launch readiness</h3>
|
||||
{!readiness ? <p className={styles.muted}>Loading…</p> : (
|
||||
@@ -291,6 +297,36 @@ function CreateRegistrationForm({ offeringId, onCreated }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SetPriceForm({ offeringId, current, onSaved }) {
|
||||
const [amount, setAmount] = useState(current.price_amount === null ? '' : (current.price_amount / 100).toFixed(2))
|
||||
const [currency, setCurrency] = useState(current.price_currency || 'usd')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.updateOffering(offeringId, {
|
||||
priceAmount: amount === '' ? null : Math.round(Number(amount) * 100),
|
||||
priceCurrency: currency,
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Price (blank = free, no checkout)</label>
|
||||
<input className={styles.input} type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Currency</label>
|
||||
<input className={styles.input} value={currency} onChange={(e) => setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} /></div>
|
||||
<button className={styles.btnSecondary} disabled={busy}>Save price</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
|
||||
const [agreementId, setAgreementId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -321,6 +357,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
|
||||
const [reg, setReg] = useState(null)
|
||||
const [agreementStatus, setAgreementStatus] = useState(null)
|
||||
const [notifs, setNotifs] = useState(null)
|
||||
const [payments, setPayments] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -335,6 +372,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
|
||||
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
|
||||
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
|
||||
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
|
||||
adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {})
|
||||
}, [registrationId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
@@ -387,6 +425,30 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Payments</h3>
|
||||
{!payments ? <p className={styles.muted}>Loading…</p> : payments.length === 0 ? <p className={styles.muted}>No checkout attempted — this offering may be free, or checkout hasn't started yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Amount</th><th>Status</th><th>Provider</th><th>Paid</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.transaction_id}>
|
||||
<td>{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}</td>
|
||||
<td><span className={`${styles.pill} ${p.status === 'paid' ? styles.pillOk : p.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{p.status.replace('_', ' ')}</span></td>
|
||||
<td className={styles.muted}>{p.provider}</td>
|
||||
<td className={styles.muted}>{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
{canAuthor && p.status === 'paid' && (
|
||||
<button className={styles.linkBtn} onClick={withBusy(() => adminApi.recordRefund(p.transaction_id, 'refunded'))}>Record refund</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Communications</h3>
|
||||
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
|
||||
|
||||
@@ -144,6 +144,9 @@ export const adminApi = {
|
||||
offering: (id) => get(`/api/admin/offerings/${id}`),
|
||||
createOffering: (fields) => post('/api/admin/offerings', fields),
|
||||
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
|
||||
updateOffering: (id, fields) => patch(`/api/admin/offerings/${id}`, fields),
|
||||
registrationPayments: (id) => get(`/api/admin/registrations/${id}/payments`),
|
||||
recordRefund: (transactionId, status) => post(`/api/admin/payments/${transactionId}/refund`, { status }),
|
||||
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
|
||||
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
|
||||
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
|
||||
|
||||
Reference in New Issue
Block a user