diff --git a/backend/content/views/offers.py b/backend/content/views/offers.py index e972cbd..261bf31 100644 --- a/backend/content/views/offers.py +++ b/backend/content/views/offers.py @@ -39,6 +39,7 @@ def _offer_to_dict(offer: Offer) -> dict: "name": offer.organization.name, "type": offer.organization.type, "country": offer.organization.country, + "website": offer.organization.website, }, "source_type": offer.source_type.name, "target_profile": offer.target_profile.name, diff --git a/frontend/src/components/sections/RoleContent.vue b/frontend/src/components/sections/RoleContent.vue index c2dab21..9faea74 100644 --- a/frontend/src/components/sections/RoleContent.vue +++ b/frontend/src/components/sections/RoleContent.vue @@ -144,6 +144,38 @@ {{ offer.organization.name }} Open ↗ +
+ Contact: + ✉ {{ bestContact(offer).value }} + in {{ bestContact(offer).name ?? 'LinkedIn profile' }} + + 👤 {{ bestContact(offer).value }} + + 🌐 {{ bestContact(offer).orgName }} website + 🔗 View opportunity +
@@ -307,6 +339,28 @@ function itemLabel(item) { return typeof item === 'string' ? item : item.label } function itemValue(item) { return typeof item === 'string' ? item : item.value } function itemType(item) { return typeof item === 'object' && item.type ? item.type : 'offer_type' } +// ── Contact fallback (email → LinkedIn → name → org website → offer link) ────── +function clean(value) { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed.length ? trimmed : null +} + +function bestContact(offer) { + const email = clean(offer.contact?.email) + if (email) return { kind: 'email', value: email } + + const linkedin = clean(offer.contact?.linkedin) + if (linkedin) return { kind: 'linkedin', value: linkedin, name: clean(offer.contact?.name) } + + const name = clean(offer.contact?.name) + if (name) return { kind: 'name', value: name } + + const website = clean(offer.organization?.website) + if (website) return { kind: 'website', value: website, orgName: offer.organization?.name } + + return { kind: 'link', value: offer.link } +} + // ── Actions ─────────────────────────────────────────────────────────────────── let debounceTimer = null diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index 72e91a7..54ba5a3 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -120,6 +120,32 @@ ℹ As a teacher, you can only add opportunities for {{ teacherOrgName ?? 'your institution' }}. +
+

Contact Information (optional)

+

Add a contact so users can reach a real person about this opportunity.

+
+ + +
+
+ + +
+

{{ contactError }}

+
+
{{ addError }}
Offer created successfully.
@@ -423,22 +449,51 @@ const BLANK_FORM = () => ({ title: '', summary: '', link: '', country: '', offer_type: '', organization_id: '', target_profile: '', status: 'draft', deadline: '', domains: [], + contact_name: '', contact_email: '', contact_phone: '', contact_linkedin: '', }) const form = reactive(BLANK_FORM()) const addLoading = ref(false) const addError = ref('') const addSuccess = ref(false) +const contactError = ref('') function resetForm() { Object.assign(form, BLANK_FORM()) addError.value = '' addSuccess.value = false + contactError.value = '' +} + +function buildContactPayload() { + const name = form.contact_name.trim() + const email = form.contact_email.trim() + const phone = form.contact_phone.trim() + const linkedin = form.contact_linkedin.trim() + + if (!name && !email && !phone && !linkedin) return { contact: undefined, error: null } + + if (!name) return { contact: undefined, error: 'Contact name is required if any contact field is filled in.' } + if (!email && !phone) return { contact: undefined, error: 'Provide a contact email or phone number.' } + + return { + contact: { name, email: email || null, phone: phone || null, linkedin: linkedin || null }, + error: null, + } } async function submitOffer() { addLoading.value = true addError.value = '' addSuccess.value = false + contactError.value = '' + + const { contact, error: contactValidationError } = buildContactPayload() + if (contactValidationError) { + contactError.value = contactValidationError + addLoading.value = false + return + } + try { const res = await api.post('/api/offers', { title: form.title, @@ -451,6 +506,7 @@ async function submitOffer() { status: form.status, deadline: form.deadline || null, domains: form.domains, + ...(contact ? { contact } : {}), }) if (res.ok) { addSuccess.value = true @@ -762,6 +818,21 @@ function formatDate(iso) { margin-bottom: 1.5rem; } +.contact-section { + border: 1px solid var(--border); + border-radius: var(--r); + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 12px; +} +.section-h3 { + font-family: 'DM Serif Display', serif; + font-size: 16px; + color: var(--ink); +} +.contact-error { color: var(--accent-mid); } + /* Form */ .offer-form { display: flex; flex-direction: column; gap: 16px; } .form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } diff --git a/frontend/src/style.css b/frontend/src/style.css index d271729..e45a3de 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1081,6 +1081,36 @@ footer { .card-org { font-size: 12px; color: #888; } +.card-contact-row { + display: flex; + align-items: center; + gap: 6px; + padding-top: 8px; + font-size: 12px; + flex-wrap: wrap; +} + +.card-contact-label { + color: #aaa; + font-weight: 500; + flex-shrink: 0; +} + +.card-contact-link { + color: #555; + text-decoration: none; + word-break: break-all; +} + +.card-contact-link:hover { + color: var(--ink); + text-decoration: underline; +} + +.card-contact-text { + color: #555; +} + .card-domains { display: flex; flex-wrap: wrap;