diff --git a/.env.example b/.env.example index f2bb6c1..0e34306 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ PRIVATE_OBA_API_KEY="test" PRIVATE_OBA_GEOCODER_API_KEY="" PRIVATE_OBA_GEOCODER_PROVIDER="google" -PRIVATE_OBACO_API_BASE_URL=https://onebusaway.co/api/v1/regions/:REGION_ID +PRIVATE_OBACO_API_BASE_URL=https://onebusaway.co/api/v1 +PRIVATE_REGION_ID= PRIVATE_OBACO_SHOW_TEST_ALERTS=false PUBLIC_NAV_BAR_LINKS={"Home": "/","About": "/about","Contact": "/contact","Fares & Tolls": "/fares-and-tolls"} diff --git a/README.md b/README.md index e3a2d04..1b21e60 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ See `.env.example` for an example of the required keys and values. - `PUBLIC_OBA_REGION_CENTER_LAT` - float: (required) The region's center latitude. - `PUBLIC_OBA_REGION_CENTER_LNG` - float: (required) The region's center longitude. - `PRIVATE_OBA_API_KEY` - string: (required) Your OneBusAway REST API server key. -- `PRIVATE_OBACO_API_BASE_URL` - string: (optional) Your OneBusAway.co server base URL, including the path prefix `/api/v1/regions/`. +- `PRIVATE_OBACO_API_BASE_URL` - string: (optional) Your OneBusAway.co server base URL, including the path prefix `/api/v1. +- `PRIVATE_REGION_ID` - string: (required if OBACO_API_BASE_URL provided). - `PRIVATE_OBACO_SHOW_TEST_ALERTS` - boolean: (optional) Show test alerts on the website. Don't set this value in production. ### Maps diff --git a/src/components/stops/StopPane.svelte b/src/components/stops/StopPane.svelte index fb0974b..095bb49 100644 --- a/src/components/stops/StopPane.svelte +++ b/src/components/stops/StopPane.svelte @@ -4,10 +4,14 @@ import LoadingSpinner from '$components/LoadingSpinner.svelte'; import Accordion from '$components/containers/SingleSelectAccordion.svelte'; import AccordionItem from '$components/containers/AccordionItem.svelte'; + import SurveyModal from '$components/surveys/SurveyModal.svelte'; import { onDestroy } from 'svelte'; - import '$lib/i18n.js'; import { isLoading, t } from 'svelte-i18n'; + import { submitHeroQuestion, skipSurvey } from '$lib/Surveys/surveyUtils'; + import { surveyStore, showSurveyModal } from '$stores/surveyStore'; + import { getUserId } from '$lib/utils/user'; + import HeroQuestion from '$components/surveys/HeroQuestion.svelte'; /** * @typedef {Object} Props @@ -28,6 +32,7 @@ let error = $state(); let interval = null; + let currentStopSurvey = $state(null); async function loadData(stopID) { loading = true; @@ -80,6 +85,52 @@ tripSelected({ detail: data }); handleUpdateRouteMap({ detail: { show } }); } + + let heroAnswer = ''; + let nextSurveyQuestion = $state(false); + let surveyPublicIdentifier = $state(null); + let showHeroQuestion = $state(true); + + async function handleNext() { + let heroQuestion = currentStopSurvey.questions[0]; + + if (heroQuestion.content.type !== 'label' && (!heroAnswer || heroAnswer.trim() === '')) { + return; + } + showSurveyModal.set(true); + nextSurveyQuestion = true; + + let surveyResponse = { + survey_id: currentStopSurvey.id, + user_identifier: getUserId(), + stop_identifier: stop.id, + stop_latitude: stop.lat, + stop_longitude: stop.lon, + responses: [] + }; + + surveyResponse.responses[0] = { + question_id: heroQuestion.id, + question_label: heroQuestion.content.label_text, + question_type: heroQuestion.content.type, + answer: heroAnswer + }; + + surveyPublicIdentifier = await submitHeroQuestion(surveyResponse); + showHeroQuestion = false; + } + + function handleSkip() { + skipSurvey(currentStopSurvey); + showHeroQuestion = false; + } + function handleHeroQuestionChange(event) { + heroAnswer = event.target.value; + } + + $effect(() => { + currentStopSurvey = $surveyStore; + }); {#if $isLoading} @@ -93,7 +144,6 @@ {#if error}

{error}

{/if} - {#if arrivalsAndDepartures}
@@ -114,6 +164,18 @@
+ {#if showHeroQuestion && currentStopSurvey} + + {/if} + {#if nextSurveyQuestion} + + {/if} + {#if arrivalsAndDepartures.arrivalsAndDepartures.length === 0}

{$t('no_arrivals_or_departures_in_next_30_minutes')}

diff --git a/src/components/surveys/HeroQuestion.svelte b/src/components/surveys/HeroQuestion.svelte new file mode 100644 index 0000000..e4dee54 --- /dev/null +++ b/src/components/surveys/HeroQuestion.svelte @@ -0,0 +1,31 @@ + + +
+ +

{currentStopSurvey.name}

+ +
+ +
+
diff --git a/src/components/surveys/SurveyModal.svelte b/src/components/surveys/SurveyModal.svelte new file mode 100644 index 0000000..8c1ef12 --- /dev/null +++ b/src/components/surveys/SurveyModal.svelte @@ -0,0 +1,201 @@ + + +{#if $showSurveyModal && currentSurvey} + +
+

{currentSurvey.name}

+
+ +
+ {#if surveySubmitted} +
+ + + +

Survey Submitted

+

+ Thank you for taking the survey! +

+
+ {:else} +
+ {#if !heroQuestionAnswered && !skipHeroQuestion} + handleInputChange(e, heroQuestion, 0)} + required={heroQuestion?.required} + error={errors[0]} + /> + {:else} +
+ {#each remainingQuestions as question, index} + handleInputChange(e, question, index + 1)} + required={question.required} + error={errors[index + 1]} + /> + {/each} +
+ {/if} +
+ +
+ {#if !heroQuestionAnswered && !skipHeroQuestion} + + {:else} + + + {/if} +
+ {/if} +
+
+{/if} diff --git a/src/components/surveys/SurveyQuestion.svelte b/src/components/surveys/SurveyQuestion.svelte new file mode 100644 index 0000000..180e888 --- /dev/null +++ b/src/components/surveys/SurveyQuestion.svelte @@ -0,0 +1,107 @@ + + + + +{#if question.content.type === 'text'} + +{:else if question.content.type === 'radio'} +
+ {#each question.content.options as option} + + {option} + + {/each} +
+{:else if question.content.type === 'checkbox'} +
+ {#each question.content.options as option} + + {option} + + {/each} +
+{:else if question.content.type === 'label'} +

+ {question.content.label_text} +

+{:else if question.content.type === 'external_survey'} +
+ + {question.content.label_text} + + {#if question.content.survey_provider} +

+ Powered by {question.content.survey_provider} +

+ {/if} +
+{/if} + +{#if error && question.content.type !== 'label'} +

This question is required.

+{/if} diff --git a/src/lib/Surveys/surveyUtils.js b/src/lib/Surveys/surveyUtils.js new file mode 100644 index 0000000..13789cd --- /dev/null +++ b/src/lib/Surveys/surveyUtils.js @@ -0,0 +1,133 @@ +import { showSurveyModal, surveyStore } from '$stores/surveyStore.js'; + +export async function loadSurveys(stop = null, userId = null) { + try { + const response = await fetch(`/api/oba/surveys?userId=${userId}`); + if (!response.ok) throw new Error('Failed to fetch surveys'); + + const data = await response.json(); + const validSurveys = getValidSurveys(data.surveys); + + let selectedSurvey = null; + + if (stop) { + selectedSurvey = + getValidStopSurvey(validSurveys, stop) || getShowSurveyOnAllStops(validSurveys); + } else { + selectedSurvey = getMapSurvey(validSurveys); + } + + surveyStore.set(selectedSurvey); + + showSurveyModal.set(selectedSurvey?.show_on_map === true); + } catch (error) { + console.error('Error loading surveys:', error); + } +} + +export function getValidSurveys(surveys) { + const now = new Date(); + return surveys.filter((survey) => { + const isValidEndDate = survey.end_date ? new Date(survey.end_date) > now : true; + const isNotAnswered = !localStorage.getItem(`survey_${survey.id}_answered`); + const isNotSkipped = !localStorage.getItem(`survey_${survey.id}_skipped`); + return isValidEndDate && isNotAnswered && isNotSkipped; + }); +} + +export function getValidStopSurvey(surveys, stop) { + for (const survey of surveys) { + if (!survey.show_on_stops) continue; + + if (survey.visible_stop_list && survey.visible_stop_list.includes(stop.id)) { + return survey; + } + + if ( + survey.visible_route_list !== null && + Array.isArray(survey.visible_route_list) && + stop.routeIds && + Array.isArray(stop.routeIds) + ) { + for (const routeId of survey.visible_route_list) { + if (stop.routeIds.includes(routeId)) { + return survey; + } + } + } + } + return null; +} + +export function getShowSurveyOnAllStops(surveys) { + return ( + surveys.find((survey) => survey.show_on_stops && survey.visible_stop_list === null) || null + ); +} + +export function getMapSurvey(surveys) { + return surveys.find((survey) => survey.show_on_map) || null; +} + +export async function submitHeroQuestion(surveyResponse) { + try { + const payload = { + ...surveyResponse, + responses: JSON.stringify([surveyResponse.responses[0]]) + }; + const response = await fetch('/api/oba/surveys/submit-survey', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error('Failed to submit survey response', response); + } + const data = await response.json(); + return data.survey_response.id; + } catch (error) { + console.error('Error submitting hero question:', error); + throw error; + } +} + +export async function updateSurveyResponse(surveyPublicIdentifier, surveyResponse) { + try { + const payload = { + ...surveyResponse, + responses: JSON.stringify(surveyResponse.responses) + }; + const response = await fetch(`/api/oba/surveys/update-survey/${surveyPublicIdentifier}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error('Failed to update survey response'); + } + return true; + } catch (error) { + console.error('Error updating survey response:', error); + throw error; + } +} + +export function submitSurvey(survey, hideSurveyModal) { + localStorage.setItem(`survey_${survey.id}_answered`, true); + if (hideSurveyModal) { + setTimeout(() => { + showSurveyModal.set(false); + }, 3000); + } +} + +export function skipSurvey(survey) { + localStorage.setItem(`survey_${survey.id}_skipped`, true); + showSurveyModal.set(false); +} diff --git a/src/lib/utils/user.js b/src/lib/utils/user.js new file mode 100644 index 0000000..41a49f4 --- /dev/null +++ b/src/lib/utils/user.js @@ -0,0 +1,16 @@ +export function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + +export function getUserId() { + let userId = getCookie('userId'); + if (!userId) { + userId = crypto.randomUUID(); + + //! Set cookie for 1 year + document.cookie = `userId=${userId}; path=/; max-age=${60 * 60 * 24 * 365}`; + } + return userId; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 21c857b..dc0dc07 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,6 +11,10 @@ import TripPlanModal from '$components/trip-planner/TripPlanModal.svelte'; import { browser } from '$app/environment'; import { PUBLIC_OBA_REGION_NAME } from '$env/static/public'; + import SurveyModal from '$components/surveys/SurveyModal.svelte'; + import { loadSurveys } from '$lib/Surveys/surveyUtils'; + import { showSurveyModal } from '$stores/surveyStore'; + import { getUserId } from '$lib/utils/user'; let stop = $state(); let selectedTrip = $state(null); @@ -47,6 +51,7 @@ stop = stopData; pushState(`/stops/${stop.id}`); showAllRoutesModal = false; + loadSurveys(stop, getUserId()); if (currentHighlightedStopId !== null) { mapProvider.unHighlightMarker(currentHighlightedStopId); } @@ -169,7 +174,10 @@ onMount(() => { loadAlerts(); - // close the trip plan modal when the tab is switched + const userId = getUserId(); + + loadSurveys(null, userId); + if (browser) { window.addEventListener('tabSwitched', () => { showTripPlanModal = false; @@ -198,7 +206,6 @@
+ {#if $showSurveyModal} + + {/if} + { + loadSurveys(stop, getUserId()); + }); diff --git a/src/stores/surveyStore.js b/src/stores/surveyStore.js new file mode 100644 index 0000000..e467eb8 --- /dev/null +++ b/src/stores/surveyStore.js @@ -0,0 +1,4 @@ +import { writable } from 'svelte/store'; + +export const showSurveyModal = writable(false); +export const surveyStore = writable(null); diff --git a/src/tests/lib/surveysUtils.test.js b/src/tests/lib/surveysUtils.test.js new file mode 100644 index 0000000..195394c --- /dev/null +++ b/src/tests/lib/surveysUtils.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + getValidSurveys, + getValidStopSurvey, + getShowSurveyOnAllStops, + getMapSurvey, + submitHeroQuestion, + updateSurveyResponse +} from '../../lib/Surveys/surveyUtils'; + +beforeEach(() => { + let store = {}; + vi.stubGlobal('localStorage', { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }) + }); + + localStorage.clear(); +}); + +describe('getValidSurveys', () => { + it('should return surveys with valid end_date or no end_date', () => { + const now = new Date(); + const futureDate = new Date(now.getTime() + 10000).toISOString(); + const pastDate = new Date(now.getTime() - 10000).toISOString(); + + const surveys = [ + { id: 1, end_date: futureDate }, + { id: 2, end_date: pastDate }, + { id: 3 } // case when end_date is not present, the survey should be valid also. + ]; + + const valid = getValidSurveys(surveys); + expect(valid).toEqual([{ id: 1, end_date: futureDate }, { id: 3 }]); + }); + + it('should filter out surveys that were answered or skipped', () => { + const now = new Date(); + const futureDate = new Date(now.getTime() + 10000).toISOString(); + + const surveys = [ + { id: 1, end_date: futureDate }, + { id: 2, end_date: futureDate } + ]; + + localStorage.setItem('survey_1_answered', 'true'); + localStorage.setItem('survey_2_skipped', 'true'); + + const valid = getValidSurveys(surveys); + expect(valid).toEqual([]); + }); +}); + +describe('getValidStopSurvey', () => { + it('should return a survey based on visible_stop_list matching stop id', () => { + const surveys = [ + { id: 1, show_on_stops: false }, + { id: 2, show_on_stops: true, visible_stop_list: ['stop1'], visible_route_list: null }, + { id: 3, show_on_stops: true, visible_stop_list: ['stop2'], visible_route_list: null } + ]; + const stop = { id: 'stop1', routeIds: [] }; + + const result = getValidStopSurvey(surveys, stop); + expect(result).toEqual(surveys[1]); + }); + + it('should return a survey based on visible_route_list matching one of stop.routeIds', () => { + const surveys = [ + { id: 1, show_on_stops: true, visible_stop_list: null, visible_route_list: ['r1', 'r2'] }, + { id: 2, show_on_stops: true, visible_stop_list: null, visible_route_list: ['r3'] } + ]; + const stop = { id: 'stop10', routeIds: ['r2'] }; + + const result = getValidStopSurvey(surveys, stop); + expect(result).toEqual(surveys[0]); + }); + + it('should return null if no survey matches', () => { + const surveys = [ + { id: 1, show_on_stops: true, visible_stop_list: ['stop3'], visible_route_list: ['r5'] } + ]; + const stop = { id: 'stop1', routeIds: ['r2'] }; + + const result = getValidStopSurvey(surveys, stop); + expect(result).toBeNull(); + }); +}); + +describe('getShowSurveyOnAllStops', () => { + it('should return survey if show_on_stops is true and visible_stop_list is null', () => { + const surveys = [ + { id: 1, show_on_stops: true, visible_stop_list: null }, + { id: 2, show_on_stops: true, visible_stop_list: ['stop1'] } + ]; + + const result = getShowSurveyOnAllStops(surveys); + expect(result).toEqual(surveys[0]); + }); + + it('should return null if no survey meets the criteria', () => { + const surveys = [ + { id: 1, show_on_stops: false, visible_stop_list: null }, + { id: 2, show_on_stops: true, visible_stop_list: ['stop1'] } + ]; + + const result = getShowSurveyOnAllStops(surveys); + expect(result).toBeNull(); + }); +}); + +describe('getMapSurvey', () => { + it('should return the survey with show_on_map true', () => { + const surveys = [ + { id: 1, show_on_map: false }, + { id: 2, show_on_map: true } + ]; + + const result = getMapSurvey(surveys); + expect(result).toEqual(surveys[1]); + }); + + it('should return null if no survey has show_on_map true', () => { + const surveys = [ + { id: 1, show_on_map: false }, + { id: 2, show_on_map: false } + ]; + + const result = getMapSurvey(surveys); + expect(result).toBeNull(); + }); +}); + +describe('submitHeroQuestion', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should resolve with data when response is ok', async () => { + const mockResponseData = { survey_response: { id: '1' } }; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => mockResponseData + }); + + const surveyResponse = { + id: 1, + user_identifier: 'user123', + stop_identifier: 'stop456', + responses: + "[{ question_id: 1, question_label: 'Question', question_type: 'radio', answer: 'yes' }]" + }; + const result = await submitHeroQuestion(surveyResponse); + + expect(result).toEqual(mockResponseData.survey_response.id); + expect(global.fetch).toHaveBeenCalled(); + }); + + it('should throw an error when response is not ok', async () => { + global.fetch.mockResolvedValue({ + ok: false + }); + + await expect(submitHeroQuestion({})).rejects.toThrow(expect.any(Error)); + }); +}); + +describe('updateSurveyResponse', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should resolve with true when response is ok', async () => { + global.fetch.mockResolvedValue({ + ok: true + }); + + const surveyPublicIdentifier = 'abc123'; + const surveyResponse = { + id: 1, + user_identifier: 'user123', + stop_identifier: 'stop456', + responses: [{ question_id: 1, answer: 'updated answer' }] + }; + + const result = await updateSurveyResponse(surveyPublicIdentifier, surveyResponse); + + expect(result).toEqual(true); + }); + + it('should throw an error when response is not ok', async () => { + global.fetch.mockResolvedValue({ + ok: false + }); + + const surveyPublicIdentifier = 'abc123'; + const surveyResponse = { + id: 1, + user_identifier: 'user123', + stop_identifier: 'stop456', + responses: [{ question_id: 1, answer: 'updated answer' }] + }; + + await expect(updateSurveyResponse(surveyPublicIdentifier, surveyResponse)).rejects.toThrow( + 'Failed to update survey response' + ); + }); +}); diff --git a/svelte.config.js b/svelte.config.js index bc1a7c0..acf72b2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -11,7 +11,8 @@ const config = { $config: './src/config', $images: './src/assets/images', $lib: './src/lib', - $src: './src' + $src: './src', + $stores: './src/stores' } }, preprocess: vitePreprocess()