Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/content/views/offers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/sections/RoleContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@
<span class="card-org">{{ offer.organization.name }}</span>
<span class="card-link-hint">Open ↗</span>
</div>
<div class="card-contact-row">
<span class="card-contact-label">Contact:</span>
<a
v-if="bestContact(offer).kind === 'email'"
:href="'mailto:' + bestContact(offer).value"
class="card-contact-link"
>✉ {{ bestContact(offer).value }}</a>
<a
v-else-if="bestContact(offer).kind === 'linkedin'"
:href="bestContact(offer).value"
target="_blank"
rel="noopener noreferrer"
class="card-contact-link"
>in {{ bestContact(offer).name ?? 'LinkedIn profile' }}</a>
<span v-else-if="bestContact(offer).kind === 'name'" class="card-contact-text">
👤 {{ bestContact(offer).value }}
</span>
<a
v-else-if="bestContact(offer).kind === 'website'"
:href="bestContact(offer).value"
target="_blank"
rel="noopener noreferrer"
class="card-contact-link"
>🌐 {{ bestContact(offer).orgName }} website</a>
<a
v-else
:href="bestContact(offer).value"
target="_blank"
rel="noopener noreferrer"
class="card-contact-link"
>🔗 View opportunity</a>
</div>
</div>
</div>

Expand Down Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions frontend/src/pages/AdminPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,32 @@
ℹ As a teacher, you can only add opportunities for <strong>{{ teacherOrgName ?? 'your institution' }}</strong>.
</div>

<div class="contact-section">
<h3 class="section-h3">Contact Information <span class="field-hint">(optional)</span></h3>
<p class="field-hint">Add a contact so users can reach a real person about this opportunity.</p>
<div class="form-row-2">
<label class="field-label">
Full Name
<input v-model="form.contact_name" class="field-input" placeholder="e.g. Sarah J. Peterson" />
</label>
<label class="field-label">
Phone
<input v-model="form.contact_phone" class="field-input" type="tel" placeholder="(415) 555-0199" />
</label>
</div>
<div class="form-row-2">
<label class="field-label">
Email
<input v-model="form.contact_email" class="field-input" type="email" placeholder="sjp.email@example.com" />
</label>
<label class="field-label">
LinkedIn Profile
<input v-model="form.contact_linkedin" class="field-input" type="url" placeholder="linkedin.com/in/sjpeterson" />
</label>
</div>
<p v-if="contactError" class="field-hint contact-error">{{ contactError }}</p>
</div>

<div v-if="addError" class="alert-error">{{ addError }}</div>
<div v-if="addSuccess" class="alert-ok">Offer created successfully.</div>

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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; }
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading