Connecting Prospective Students to Admission Counselors with SchoolDigger
Imagine: you’re looking at attending a college, and you’re trying to get in contact with someone from the admission office, but there’s no clear information about who specifically to contact on the website. You stumble upon a page listing out a full team of admission counselors with their names and email addresses and what they’re responsible for but you don’t feel like reading it all, and you don’t know which of them to contact, so you just pick one. Which one did you pick? Chances are, you chose the first one. Or, perhaps you’re more adventurous, and you closed your eyes, did one of those super scrolls and then selected one blindly. A friendly game of counselor roulette never hurt anyone.
Regardless of your method, there’s gotta be a better way from the website UI standpoint, right?
The Problem
Similar to the admissions setup at other organizations, our admission counselors are assigned to either geographical territories or categories of students. For example, one may be assigned to the state of Texas, another to Charleston county in SC, and another may be assigned to all military and veteran students, or all non-degree-seeking students. Generally, there’s a pretty good mix of assignments, and prospective students visiting the website didn’t have a clear view of these.
The Solution
We need a custom Meet Your Admission Counselor feature that intelligently connects students with the right counselor based on their high school or student category. In this article, I walk through how I built that feature using JavaScript, JSON, SchoolDigger, and PHP.
Dynamic Counselor Lookup: High-Level
In order to build a solution with so much data involved (a ton of different admission counselors, each with different responsibilities, where those responsibilities have different “types” like states, counties, and student categories), I first needed to organize a proper flow of questions to achieve a simple front end UI. By that, I mean I wanted to 1) avoid asking redundant or unnecessary questions and 2) keep the number of steps to finding the right counselor as low as possible.
So, a simple look at our complicated assignment data:
- Non-territory (“other”) assignment: this kind of assignment is categorical; a counselor may be assigned to one or more categories of incoming students like: summer school students, readmits, non-degree, dual enrollment, 60+, military and veteran, transfer, international, etc.
- Territory assignment: for first-year students, assignment is territory-based. For students in SC and NC, assignment is county-level (because we get more applicants from these two); for students outside of SC and NC, assignment is state-level.
The categorical assignment is easy. If a student chooses a category, they should be shown the admission counselor assigned to that category. Boom. EZPZ.
The territory assignment is a bit more difficult. We need to ask the fewest questions possible to know the territory from which the student originates, and we need to then map that territory to the counselor responsible. If it’s an SC or NC territory, we need to map via the county, otherwise we need to map via the state.
The above two truths made building the first part of the UI straightforward. Students should first start by selecting their category with an “I am applying as a…” dropdown.
Most of the categories will map to one respective counselor. If any of those are selected, we immediately render the information for the corresponding counselor below the dropdown and the student has their contact.
In that same dropdown, we also include a “First-Year student” option. For First-Year students, after they select this option, they are presented with a simple input field asking for the name of their high school. As they type, we query SchoolDigger via the SchoolDigger API to auto-suggest schools based on the current input value. To make this even more beautiful, each of these school results from SchoolDigger has corresponding location data.
Once they see their high school in the suggestions list, they click their school, and we use that click event to:
- Extract the location of that school
- Match that location to a counselor
- Render the information for that counselor below the form. If multiple matching counselors are found, we show all of them with the note that the student can choose any of them.
Sounds easy, right? Well, it’s never quite that simple. Here’s how we did it.
Step 1: Setting Up the Counselor Territories Data
We started by creating a JSON file (counselor-territories-data.json
) that mapped counselors to their respective territories. For non-geographical categories (like Transfer or International students), we also tracked their responsibilities.
Here’s a sneak peek of our JSON, but with some fake counselors and data to keep things light:
[
{
"counselor": {
"name": "Frodo Baggins",
"email": "frodo@shire.edu",
"photo": "/images/counselors/frodo.jpg",
"description": "Frodo has extensive experience navigating difficult terrain and guiding students through admission challenges. He's particularly great at helping students on long academic journeys.",
"pageLink": "/admission/counselors/frodo.php"
},
"territories": [
{
"state": "SC",
"county": "Charleston"
},
{
"state": "SC",
"county": "Berkeley"
}
],
"otherResponsibilities": ["Military and Veteran"]
},
{
"counselor": {
"name": "Hermione Granger",
"email": "hermione@hogwarts.edu",
"photo": "/images/counselors/hermione.jpg",
"description": "Hermione specializes in helping transfer students and guiding them through complex admissions spells.",
"pageLink": "/admission/counselors/hermione.php"
},
"territories": [],
"otherResponsibilities": ["Transfer", "International"]
},
{
"counselor": {
"name": "Scooby Doo",
"email": "scoobydoo@ruhroh.edu",
"photo": "/images/counselors/scooby.jpg",
"description": "Scooby loves helping students solve mysteries and guiding them through scary challenges",
"pageLink": "/admission/counselors/scoobydoo.php"
},
"territories": [
{
"state": "AK",
"county": ""
},
{
"state": "CA",
"county": ""
},
{
"state": "CO",
"county": ""
},
{
"state": "HI",
"county": ""
},
{
"state": "WY",
"county": ""
}
],
"otherResponsibilities": ["Transfer", "International"]
}
]
This file (which in reality is quite large) is loaded into our system to serve as the map for linking students to counselors based on geographical and categorical data.
We serve this data via a PHP file that our primary index.js file can fetch from:
<?php
// counselor-territories-data.php
header('Content-Type: application/json; charset=utf-8');
echo file_get_contents(__DIR__ . '/counselor-territories-data.json');
Step 2: Handling SC and NC Schools with a City-County JSON Map
For SC and NC, we had an added complexity: while students are assigned to counselors by county, SchoolDigger returns school data by city. This meant we had to build another JSON map to connect cities to counties for SC and NC.
Here’s a snippet from our sc-nc-city-county-map-data.json
:
{
"SC": [
{
"city": "Abbeville",
"county": "Abbeville"
},
{
"city": "Adamsburg",
"county": "Union"
},
{
"city": "Adams Run",
"county": "Charleston"
},
...
],
"NC": [
{
"city": "City/Town",
"county": "County"
},
{
"city": "Aberdeen",
"county": "Moore"
},
{
"city": "Ahoskie",
"county": "Hertford"
},
...
]
}
This JSON file is crucial for ensuring that students in South Carolina and North Carolina get matched with counselors by county.
Similar to the counselor territories data file, we serve this one with a PHP file that echoes the JSON contents:
<?php
// sc-nc-city-county-map.php
header('Content-Type: application/json; charset=utf-8');
echo file_get_contents(__DIR__ . '/sc-nc-city-county-map-data.json');
Step 3: Querying the SchoolDigger API
When first-year students are typing the name of their high school in the high school name field, we need to query SchoolDigger to find schools with partially-matching names.
SchoolDigger’s API allows us to execute this query programmatically very easily, as seen above. Note that to protect our API key (and avoid public exposure), we used a PHP proxy rather than invoking their API directly from the client in JS.
Here’s the PHP script (get-schools.php
) that handles the proxying:
<?php
// Using config.ini for local development so we can avoid hardcoding values in version control.
$SCHOOLDIGGER_APP_ID = parse_ini_file("config.ini")["SCHOOLDIGGER_APP_ID"];
$SCHOOLDIGGER_API_KEY = parse_ini_file("config.ini")["SCHOOLDIGGER_API_KEY"];
$BASE_SCHOOLDIGGER_URL = "https://api.schooldigger.com";
if (isset($_GET['q'])) {
$query_array = array(
'q' => $_GET['q'],
'appID' => $SCHOOLDIGGER_APP_ID,
'appKey' => $SCHOOLDIGGER_API_KEY,
'returnCount' => 20,
'qSearchCityStateName' => TRUE
);
$query = http_build_query($query_array);
header('Content-Type: application/json; charset=utf-8');
$url = $BASE_SCHOOLDIGGER_URL . "/v2.0/autocomplete/schools?" . $query;
echo file_get_contents($url);
}
?>
This script takes the school query from the front-end, hits the SchoolDigger API with the query and returns the results to our JavaScript code. This way, we never expose our sensitive API keys to the client.
One important detail to consider:
Whenever you’re building an API-fed autosuggestions list, you should add a delay, or buffer, after the input event so that you’re not making an API call immediately each time a key is pressed.
Step 4: Putting it all Together with HTML and JS
Below is a barebones version of the HTML form containing the elements I’ve described:
<div class="wysiwyg component">
<div class="wysiwyg__inner user-markup">
<h1 class="font-h1">Meet Your Counselor</h1>
<p><img alt="Members of the College of Charleston Admissions Staff stand outside the Meyer Alumni Welcome Center" class="" height="810" src="https://charleston.edu/admission/_files/admissions-staff.jpg" width="1440" /></p>
<p>College of Charleston admission counselors are your primary point of contact as you navigate this process. Please select the student type that best identifies you. <strong>When you select First-Year Student, you will be asked to type in your high school. Please allow for a few seconds for the list of high schools to load.</strong> Once you select your high school, you should see your counselor by scrolling down.</p><br />
<form class="form-inline">
<div class="student-type-choice-container text-center">
<div><label for="student-type-selector"> <strong>I am applying as a... </strong> </label></div>
<div><select class="student-type-selector text-center" id="student-type-selector" name="student-type">
<option value="-----">-----</option>
<option value="First-Year">First-Year student</option>
<option value="International (non-US)">International (non-US) student</option>
<option value="Dual Enrollment">Dual Enrollment student</option>
<option value="Summer School">Summer School student</option>
<option value="Home School">Home Schooled student</option>
<option value="GED">GED student</option>
<option value="Transfer Student">Transfer student</option>
<option value="Military and Veteran">Military or Veteran student</option>
<option value="60+">60+ Tuition Exemption Program student</option>
<option value="Readmit">Readmit student</option>
<option value="Non-Degree">Non-Degree-Seeking student</option>
<option value="Bachelor of Professional Studies">Bachelor of Professional Studies</option>
</select></div>
</div>
</form>
<div class="search-container">
<form class="high-school-search"><label for="high-school-search"> <strong>Please enter the name of your high school and then choose yours from the suggested results.</strong> </label> <input class="high-school-search" id="school-search" type="text" />
<div class="suggestions" id="suggestions"></div>
</form>
</div>
<div class="counselor-info-container-label text-center"></div>
<div class="row" id="counselor-info-container"></div>
<div id="extra-info">
<p>If you are unable to find your counselor, call <a href="tel:8439535670">843.953.5670</a> or email <a href="admissions@cofc.edu">admissions@cofc.edu</a> to connect with the Office of Admissions.</p>
</div>
<link href="https://charleston.edu/static/css/style.css" rel="stylesheet" />
<script src="/counselors/meet-your-counselor/index.js" type="text/javascript"></script>
</div>
</div>
Next step is the interactivity! I’ll include the full, non-sensitive JavaScript file that drives the app below with comments explaining what’s going on.
// declare data here to set up global access
let COUNSELOR_TERRITORIES, SC_NC_CITY_COUNTY_MAP;
// TESTING is true if serving from localhost or 127.0.0.1
let TESTING = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1";
let BASE_MEET_YOUR_COUNSELORS_URL = TESTING ? '' : `/admission/counselors/meet-your-counselor`;
// define a shorthand function for logging
const debug = TESTING ? console.log : () => { };
function delay(fn, ms) {
// avoid invoking APIs immediately on input by using a delay
let timer = 0;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(fn.bind(this, ...args), ms || 0);
};
}
let createCounselorElement = (counselorObject, numCounselors) => {
// create and render an element to display information about a counselor using
// counselor JSON data which came from counselor-territories-data
debug({
'action': 'createCounselorElement',
'counselorObject': counselorObject,
'numCounselors': numCounselors
});
let cContainer = document.createElement("div");
cContainer.classList.add("counselor");
let label = "";
let gridclass = "";
if (numCounselors > 1) {
label = counselorObject.name;
if (numCounselors == 2) {
gridclass = "span6";
} else if (numCounselors >= 3) {
gridclass = "span4";
}
} else {
gridclass = "span12";
label = `Your admissions counselor is ${counselorObject.name}!`;
}
cContainer.classList.add(gridclass);
let cImage = document.createElement("img");
cImage.src = `${counselorObject.photo}`;
let cName = document.createElement("strong");
cName.textContent = label;
let cDescription = document.createElement("p");
// do not display raw HTML; parse the HTML
cDescription.innerHTML = counselorObject.description;
let cPageLink = document.createElement("a");
let firstName = counselorObject.name.split(" ")[0];
cPageLink.href = counselorObject.pageLink;
cPageLink.textContent = `Read More About ${firstName}!`;
cPageLink.classList.add("cta");
cContainer.appendChild(cImage);
cContainer.appendChild(cName);
cContainer.appendChild(cDescription);
cContainer.appendChild(cPageLink);
return cContainer;
};
let filterOnlyHighSchools = (schoolMatches) => {
// schooldigger data has non-high schools included in results
// and we are only interested in the student's high schools so
// filter them out based on lowGrade and highGrade range of 09-12
debug({
'action': 'filterOnlyHighSchools',
'schoolMatches': schoolMatches
});
// lowgrade = '09', highgrade = '12'
return schoolMatches.filter((school) => school.lowGrade === "09" && school.highGrade === "12");
}
let clearChildren = (parent) => {
// utility method to clear HTML elements out of a parent element
debug({
'action': 'clearChildren',
'parent': parent
});
while (parent.firstChild) {
parent.removeChild(parent.firstChild);
}
};
// wait until DOM is loaded
document.addEventListener("DOMContentLoaded", () => {
debug('DOMContentLoaded');
// get all of the main elements
let suggestions = document.getElementById("suggestions");
let counselorInfoContainer = document.getElementById("counselor-info-container");
let counselorInfoContainerLabel = document.querySelector(".counselor-info-container-label");
let searchContainer = document.querySelector(".search-container");
// define a function to execute when a school button (in the auto suggestions list)
// is clicked
let schoolButtonClickFunction = ({ event, counselors, state, city }) => {
event.preventDefault(); // prevent the form from submitting and the page from refreshing
// when a school button is clicked, display the counselor info
// corresponding to that school's location
debug('schoolButtonClickFunction', { counselors, state, city });
// create and render counselor information for each counselor assigned to this school
counselors = counselors.map((c) => c.counselor);
counselorInfoContainer.innerHTML = "";
counselorInfoContainerLabel.innerHTML = "";
if (counselors.length > 1) {
debug('multiple counselors', counselors);
counselorInfoContainerLabel.innerHTML = "<h4>Your admission counselor is one of the following people!</h4>";
}
counselors.forEach((counselorObj) => {
let cContainer = createCounselorElement(counselorObj, counselors.length);
counselorInfoContainer.appendChild(cContainer);
setTimeout(() => {
cContainer.classList.add("show");
cContainer.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" });
}, 500);
});
};
const removeDuplicateCounselorsByEmail = (counselors) => {
// utility function to remove duplicate counselors
debug({
'action': 'removeDuplicateCounselorsByEmail',
'counselors': counselors
});
return counselors.filter((counselor, index, self) => {
return (
self.findIndex((c) => {
return c.email === counselor.email;
}) === index
);
});
};
// add an input event listener for the main school-search field to
// set up the auto-suggestions list
$("#school-search").on(
"input",
delay((event) => {
// if enter key was pressed, do nothing (prevent form submission and page refresh)
if (event.keyCode === 13) {
event.preventDefault();
return;
}
debug({
'action': 'school-search',
'event': event
});
clearChildren(suggestions);
clearChildren(counselorInfoContainer);
let val = event.target.value;
if (val.trim() === "") {
// DO NOTHING IF FIELD IS EMPTY
return;
} else {
// FIELD NOT EMPTY, QUERY API
let url = `${BASE_MEET_YOUR_COUNSELORS_URL}/get-schools.php?q=${encodeURIComponent(val)}`;
debug({
'action': 'fetching schools',
'url': url
})
fetch(url)
.then((response) => response.json())
// NOT ALL OF THE SCHOOLS FROM SCHOOLDIGGER ARE HIGH SCHOOLS,
// SO WE NEED TO FILTER LOCALLY WITH JS
.then((data) => filterOnlyHighSchools(data.schoolMatches))
.then((highSchoolMatches) => {
debug({
'action': 'filtered high schools',
'highSchoolMatches': highSchoolMatches
})
if (highSchoolMatches.length === 0) {
// IF NO MATCHES PRESENT, BE CLEAR ABOUT THAT
let noResults = document.createElement("p");
$(noResults).css({ "color": "red", "font-weight": "bold", "font-size": "1.5em", "margin-top": "1em" })
noResults.textContent = "Sorry, we cannot find a high school with that name! Try a different query.";
suggestions.appendChild(noResults);
return;
}
// FOR EACH HIGH SCHOOL, RENDER A BUTTON WITH AN EVENT LISTENER
// THAT WILL RENDER THE CORRESPONDING COUNSELOR INFO
highSchoolMatches.forEach((element) => {
let counselors = [];
if (element.state === "SC" || element.state === "NC") {
// MATCH BY COUNTY FOR SC OR NC
// GET THE COUNTY FROM THE CITY TO COUNTY MAP FOR SC / NC
let county = SC_NC_CITY_COUNTY_MAP[element.state].filter((el) => el.city === element.city)[0].county;
// EXTEND COUNSELORS ARRAY WITH COUNSELORS WHOSE state=element.state AND county=county
const counselorsForCounty = COUNSELOR_TERRITORIES.filter(
(counselor) =>
counselor.territories.filter(
(t) =>
t.state.replaceAll(".", "").trim() == element.state &&
t.county.replaceAll(" County", "").trim() == county
).length > 0
)
debug({
'county': county,
'counselorsForCounty': counselorsForCounty,
})
counselors.push(...counselorsForCounty);
} else {
// NON-SC / NON-NC SCHOOL, SO MATCH BY STATE ONLY
counselors.push(
...COUNSELOR_TERRITORIES.filter(
(counselor) =>
counselor.territories.filter((t) => t.state.replaceAll(".", "").trim() == element.state).length >
0
)
);
}
// REMOVE DUPLICATES (added afterward, noticed duplication issue in results)
counselors = removeDuplicateCounselorsByEmail(counselors);
let schoolButton = document.createElement("button");
["school-button", "btn", "btn--primary-small"].forEach((cls) => {
schoolButton.classList.add(cls);
});
schoolButton.innerHTML = `
<span class="text">${element.schoolName} - ${element.city}, ${element.state}</span>`;
schoolButton.id = element.schoolid;
suggestions.appendChild(schoolButton);
document.getElementById(schoolButton.id).addEventListener("click", (e) => {
// THIS FUNCTION JUST RENDERS THE COUNSELOR(S)
schoolButtonClickFunction({ event: e, counselors: counselors, state: element.state, city: element.city });
});
});
}).catch((err) => {
console.error(err);
});
}
}, 1000)
);
// function to fetch and store the global data using the PHP proxy APIs
const fetchAndStoreGlobalData = () => {
// globally store the counselor territories and the SC/NC city-county map
// by fetching from PHP files which relay JSON file content from CMS
fetch(`${BASE_MEET_YOUR_COUNSELORS_URL}/counselor-territories.php`)
.then((response) => response.json())
.then((data) => {
COUNSELOR_TERRITORIES = data;
});
fetch(`${BASE_MEET_YOUR_COUNSELORS_URL}/sc-nc-city-county-map.php`)
.then((response) => response.json())
.then((data) => {
SC_NC_CITY_COUNTY_MAP = data;
});
}
// execute that immediately when DOM is loaded
fetchAndStoreGlobalData();
// when the student selects their type from the "I am applying as a..." dropdown
// handle the form flow (move to school search if First-Year, otherwise match by "otherResponsibilities")
$("#student-type-selector").on("change", (event) => {
// only show the search container if the student type is "First-Year"
let counselors;
let v = event.target.value;
counselorInfoContainer.innerHTML = "";
counselorInfoContainerLabel.innerHTML = "";
if (v != "First-Year") {
searchContainer.style.display = "none";
counselors = COUNSELOR_TERRITORIES.filter((c) => c.otherResponsibilities.includes(v));
if (counselors.length > 1) {
counselorInfoContainerLabel.innerHTML = "<h4>Your admission counselor is one of the following people!</h4>";
}
counselors.forEach((counselor) => {
let cContainer = createCounselorElement(counselor.counselor, counselors.length);
counselorInfoContainer.appendChild(cContainer);
setTimeout(() => {
cContainer.classList.add("show");
cContainer.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" });
}, 500);
});
} else {
searchContainer.style.display = "block";
}
});
});
The end result looks like this:
Conclusion
What started as a simple idea — helping students find their admissions counselor quickly and easily rather than playing a guessing game — turned into an exercise in API calls, data mapping, and JS event listeners. With the help of SchoolDigger’s API, a couple of custom JSON files, and some PHP backend work, we now have a user-friendly feature that connects students with the right counselor every time.
I’ve since built this into the framework of our main CMS so that the data, while rendered and served in JSON for the app, is editable and publishable by our University Marketing team without having to deal with anything that looks like code. Shoutout Velocity.