Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/surveys #170

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9988c82
Add alias for stores directory in Svelte config
Ahmedhossamdev Feb 6, 2025
8f8c298
Add SurveyModal component
Ahmedhossamdev Feb 6, 2025
6f852ab
Add hero question survey functionality to StopPane component
Ahmedhossamdev Feb 6, 2025
917ea7a
Add survey API endpoints for fetching, submitting, and updating surve…
Ahmedhossamdev Feb 6, 2025
2747570
Update alerts API endpoint to include region path in URL
Ahmedhossamdev Feb 6, 2025
3dfe06b
Add survey store
Ahmedhossamdev Feb 6, 2025
f8f6581
Add surveys
Ahmedhossamdev Feb 6, 2025
bdc712a
Add SurveyQuestion component
Ahmedhossamdev Feb 6, 2025
5b6d186
Add survey utility functions for loading and managing survey responses
Ahmedhossamdev Feb 6, 2025
7e83ae9
Add user utility functions for managing user ID cookies
Ahmedhossamdev Feb 6, 2025
1c49741
Update .env.example to include region ID for OneBusAway API
Ahmedhossamdev Feb 6, 2025
bac3d5e
Update README.md
Ahmedhossamdev Feb 6, 2025
2dc3531
Refactorfunctions to use 'stop' parameter instead of stopId and add f…
Ahmedhossamdev Feb 6, 2025
a3402d0
Update loadSurveys function to use 'stop' parameter instead of stop.id
Ahmedhossamdev Feb 6, 2025
7c241f0
Add onMount lifecycle to load surveys using stop and user ID
Ahmedhossamdev Feb 6, 2025
6da2b3a
Linting and formatting
Ahmedhossamdev Feb 6, 2025
ab08be3
Remove unused 'time' import from +page.svelte
Ahmedhossamdev Feb 6, 2025
a6e1215
Fix typo
Ahmedhossamdev Feb 6, 2025
c1a67cf
Rename surveyPublicIdentifierOutside to surveyPublicId
Ahmedhossamdev Feb 6, 2025
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<YOUR REGION ID>`.
- `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
Expand Down
89 changes: 87 additions & 2 deletions src/components/stops/StopPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 SurveyQuestion from '$components/surveys/SurveyQuestion.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';

/**
* @typedef {Object} Props
Expand All @@ -28,6 +32,7 @@
let error = $state();

let interval = null;
let currentStopSurvey = $state(null);

async function loadData(stopID) {
loading = true;
Expand Down Expand Up @@ -80,6 +85,49 @@
tripSelected({ detail: data });
handleUpdateRouteMap({ detail: { show } });
}

let heroAnswer = '';
let nextSurveyQuestion = $state(false);
let surveyPublicIdentifier = $state(null);
let showHeroQuestion = $state(true);

async function handleNext() {
if (heroAnswer && heroAnswer.trim() != '') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opt for an early return here to avoid unnecessary nesting.

if (!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: currentStopSurvey.questions[0].id,
question_label: currentStopSurvey.questions[0].content.label_text,
question_type: currentStopSurvey.questions[0].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;
});
</script>

{#if $isLoading}
Expand All @@ -93,7 +141,6 @@
{#if error}
<p>{error}</p>
{/if}

{#if arrivalsAndDepartures}
<div class="space-y-4">
<div>
Expand All @@ -114,6 +161,44 @@
</div>
</div>
</div>
{#if showHeroQuestion && currentStopSurvey}
<div class="hero-question-container relative rounded-lg bg-gray-50 p-6 shadow">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please extract the content inside of the {#if} into a new component.

<button
onclick={handleSkip}
class="absolute right-2 top-2 text-2xl text-gray-500 hover:text-gray-700"
title="Skip hero question"
>
&times;
</button>
<h2 class="h2 mb-4">{currentStopSurvey.name}</h2>
<SurveyQuestion
question={currentStopSurvey.questions[0]}
index={0}
required={currentStopSurvey.questions[0].required}
onInputChange={handleHeroQuestionChange}
variant="compact"
error={[false]}
/>
<div class="mt-4 flex justify-end">
<button
onclick={handleNext}
class="rounded bg-green-500 px-4 py-3 text-white shadow transition hover:bg-green-600"
>
Next
</button>
</div>
</div>
{/if}

{#if nextSurveyQuestion}
<SurveyModal
currentSurvey={currentStopSurvey}
{stop}
skipHeroQuestion={true}
surveyPublicIdentifierOutside={surveyPublicIdentifier}
/>
{/if}

{#if arrivalsAndDepartures.arrivalsAndDepartures.length === 0}
<div class="flex items-center justify-center">
<p>{$t('no_arrivals_or_departures_in_next_30_minutes')}</p>
Expand Down
201 changes: 201 additions & 0 deletions src/components/surveys/SurveyModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<script>
import { Modal, Button } from 'flowbite-svelte';
import SurveyQuestion from './SurveyQuestion.svelte';
import {
submitHeroQuestion as submitHeroQuestionUtil,
updateSurveyResponse as updateSurveyResponseUtil,
skipSurvey,
submitSurvey
} from '$lib/Surveys/surveyUtils';

import { showSurveyModal, surveyStore } from '$stores/surveyStore';
import { getUserId } from '$lib/utils/user';

let { stop = $bindable(null), skipHeroQuestion, surveyPublicIdentifierOutside } = $props();

let userAnswers = $state([]);
let heroQuestionAnswered = $state(false);
let heroQuestion = $state(null);
let remainingQuestions = $state([]);
let surveyPublicIdentifier = $state(null);
let surveySubmitted = $state(false);
let errors = $state([]);

let currentSurvey = $state($surveyStore);

if (currentSurvey && currentSurvey.questions) {
heroQuestion = currentSurvey.questions[0];
remainingQuestions = currentSurvey.questions.slice(1);
}

let surveyResponse = {
survey_id: currentSurvey.id,
user_identifier: getUserId(),
stop_identifier: stop?.id ?? null,
stop_latitude: stop?.lat ?? null,
stop_longitude: stop?.lon ?? null,
responses: []
};

function handleInputChange(event, question, index) {
const type = question.content.type;

if (type === 'text' || type === 'radio') {
userAnswers[index] = event.target.value;
} else if (type === 'checkbox') {
const value = event.target.value;
if (event.target.checked) {
userAnswers[index] = [...(userAnswers[index] || []), value];
} else {
userAnswers[index] = (userAnswers[index] || []).filter((option) => option !== value);
if (userAnswers[index].length === 0) {
delete userAnswers[index];
}
}
}

surveyResponse.responses[index] = {
question_id: question.id,
question_label: question.content.label_text,
question_type: question.content.type,
answer: userAnswers[index] || null
};
}

function validateAnswers() {
let valid = true;
errors = new Array(currentSurvey.questions.length).fill(false);

if (!heroQuestionAnswered && !skipHeroQuestion) {
if (heroQuestion.required && (!userAnswers[0] || userAnswers[0].length === 0)) {
errors[0] = true;
valid = false;
}
} else {
remainingQuestions.forEach((question, index) => {
const answer = userAnswers[index + 1];
if (question.required && (!answer || (Array.isArray(answer) && answer.length === 0))) {
errors[index + 1] = true;
valid = false;
}
});
}

return valid;
}

async function submitHeroQuestion() {
if (!validateAnswers()) return;

try {
surveyPublicIdentifier = await submitHeroQuestionUtil(surveyResponse);
heroQuestionAnswered = true;
submitSurvey(currentSurvey, false);
} catch (error) {
console.error('Error submitting hero question:', error);
}
}

async function updateSurveyResponse() {
if (surveyPublicIdentifierOutside) [(surveyPublicIdentifier = surveyPublicIdentifierOutside)];
updateSurveyResponseUtil(surveyPublicIdentifier, surveyResponse);
}

function handleSubmit() {
if (!validateAnswers()) return;

updateSurveyResponse();
surveySubmitted = true;
submitSurvey(currentSurvey, true);
}
</script>

{#if $showSurveyModal && currentSurvey}
<Modal open={$showSurveyModal} size="3xl" class="max-w-5xl rounded-2xl">
<div
class="flex items-center justify-between rounded-t-2xl border-b border-gray-200 p-6 dark:border-gray-700"
>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{currentSurvey.name}</h2>
</div>

<div class="flex flex-col space-y-6 p-6">
{#if surveySubmitted}
<div class="flex flex-1 flex-col items-center justify-center p-12">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-20 w-20 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<h2 class="mt-6 text-4xl font-bold text-gray-900 dark:text-white">Survey Submitted</h2>
<p class="mt-3 text-xl text-gray-700 dark:text-gray-300">
Thank you for taking the survey!
</p>
</div>
{:else}
<div class="max-h-[60vh] overflow-y-auto p-6">
{#if !heroQuestionAnswered && !skipHeroQuestion}
<SurveyQuestion
question={heroQuestion}
index={0}
value={userAnswers[0]}
onInputChange={(e) => handleInputChange(e, heroQuestion, 0)}
required={heroQuestion?.required}
error={errors[0]}
/>
{:else}
<div class="space-y-8">
{#each remainingQuestions as question, index}
<SurveyQuestion
{question}
index={index + 1}
value={userAnswers[index + 1]}
onInputChange={(e) => handleInputChange(e, question, index + 1)}
required={question.required}
error={errors[index + 1]}
/>
{/each}
</div>
{/if}
</div>

<div
class="sticky bottom-0 flex justify-end gap-4 rounded-b-2xl border-t border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
{#if !heroQuestionAnswered && !skipHeroQuestion}
<Button
onclick={submitHeroQuestion}
color="green"
class="rounded-lg px-10 py-3 shadow-md transition-shadow hover:shadow-lg"
>
Next
</Button>
{:else}
<Button
onclick={skipSurvey}
color="red"
class="rounded-lg px-10 py-3 shadow-md transition-shadow hover:shadow-lg"
>
Skip
</Button>
<Button
onclick={handleSubmit}
color="green"
class="rounded-lg px-10 py-3 shadow-md transition-shadow hover:shadow-lg"
>
Submit
</Button>
{/if}
</div>
{/if}
</div>
</Modal>
{/if}
Loading