CryptoFlow: Building a secure and scalable system with Axum and SvelteKit - Part 6
Tags: webdev, svelte, rust, javascript
Published: 2024-05-23 18:01:59

## Introduction

It's been a while since I last updated this series of articles. I have been away, and I sincerely apologize for the abandonment. I will be completing the series by going through the frontend code and other updates I made at the backend. Let's get into it!

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/cryptoflow %}

I also have the application live. You can interact with it [here][1]. Please note that the backend was deployed on [Render][2] which:

> Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.

## Implementation

### Step 1: Landing page

Our application will have a landing page where questions are listed. The page will be split into three resizable columns:

1. Left column: This will contain developer information and some coins with their rankings. The number of coins at a time will be specified by the `NUM_OF_COINS_TO_SHOW` constant which is `10` by default but can be made configurable. Every 10 seconds, the list will change.

2. Middle column: A list of questions will be housed here.

3. Right column: We will have some charts for plotting the `prices`, `market caps`, and `total volumes` of any selected coin. Making a total of three multi-line charts. Each line corresponds to each of the coins. We will provide users with two inputs where they can select up to 4 coins at a time, and the number of days they want their histories to be shown.

These entire requirements are implemented in `frontend/src/routes/+page.svelte`:

```html
<script>
  import Charts from "$lib/components/Charts.svelte";
  import { NUM_OF_COINS_TO_SHOW } from "$lib/utils/constants.js";
  import { onDestroy, onMount } from "svelte";

  export let data,
    /** @type {import('./$types').ActionData} */
    form;

  /**
   * @typedef {Object} Coin
   * @property {string} id - The id of the coin.
   * @property {string} name - The name of the coin.
   * @property {string} symbol - The symbol of the coin.
   * @property {string} image - The image of the coin.
   * @property {number} market_cap_rank - The market cap rank of the coin.
   */

  /**
   * @type {Coin[]}
   */
  let selectedCoins = [],
    /** @type {Number} */
    intervalId;

  $: ({ questions, coins } = data);

  const selectCoins = () => {
    const selectedCoinsSet = new Set();
    while (selectedCoinsSet.size < NUM_OF_COINS_TO_SHOW) {
      const randomIndex = Math.floor(Math.random() * coins.length);
      selectedCoinsSet.add(coins[randomIndex]);
    }
    selectedCoins = Array.from(selectedCoinsSet);
  };

  onMount(() => {
    selectCoins(); // Select coins immediately on mount
    intervalId = setInterval(selectCoins, 10000); // Select coins every 10 seconds
  });

  onDestroy(() => {
    clearInterval(intervalId); // Clear the interval when the component is destroyed
  });
</script>

<div class="flex flex-col md:flex-row text-[#efefef]">
  <!-- Left Column for Tags -->
  <div class="hidden md:block md:w-1/4 p-4 resize overflow-auto">
    <!-- Developer Profile Card -->
    <div
      class="bg-[#041014] hover:bg-black border border-black hover:border-[#145369] rounded-lg shadow p-4 mb-1"
    >
      <img
        src="https://media.licdn.com/dms/image/D4D03AQElygM4We8kqA/profile-displayphoto-shrink_800_800/0/1681662853733?e=1721865600&v=beta&t=idb1YHHzZbXHJ1MxC4Ol2ZnnbyCHq6GDtjzTzGkziLQ"
        alt="Developer"
        class="rounded-full w-24 h-24 mx-auto mb-3"
      />
      <h3 class="text-center text-xl font-bold mb-2">John O. Idogun</h3>
      <a
        href="https://github.com/sirneij"
        class="text-center text-blue-500 block mb-2"
      >
        @SirNeij
      </a>
      <p class="text-center">Developer & Creator of CryptoFlow</p>
    </div>
    <div
      class="bg-[#041014] p-6 rounded-lg shadow mb-6 hover:bg-black border border-black hover:border-[#145369]"
    >
      <h2 class="text-xl font-semibold mb-4">Coin ranks</h2>
      {#each selectedCoins as coin (coin.id)}
      <div
        class="flex items-center justify-between mb-2 border-b border-[#0a0a0a] hover:bg-[#041014] px-3 py-1"
      >
        <div class="flex items-center">
          <img
            class="w-8 h-8 rounded-full mr-2 transition-transform duration-500 ease-in-out transform hover:rotate-180"
            src="{coin.image}"
            alt="{coin.name}"
          />
          <span class="mr-2">{coin.name}</span>
        </div>
        <span
          class="inline-block bg-blue-500 text-white text-xs px-2 rounded-full uppercase font-semibold tracking-wide"
        >
          #{coin.market_cap_rank}
        </span>
      </div>
      {/each}
    </div>
  </div>

  <div class="md:w-5/12 py-4 px-2 resize overflow-auto">
    {#if questions} {#each questions as question (question.id)}
    <div
      class="
				bg-[#041014] mb-1 rounded-lg shadow hover:bg-black border border-black hover:border-[#145369]"
    >
      <div class="p-4">
        <a
          href="/questions/{question.id}"
          class="text-xl font-semibold hover:text-[#2596be]"
        >
          {question.title}
        </a>
        <!-- <p class="mt-2">{article.description}</p> -->
        <div class="mt-3 flex flex-wrap">
          {#each question.tags as tag}
          <span
            class="mr-2 mb-2 px-3 py-1 text-sm bg-[#041014] border border-[#145369] hover:border-[#2596be] rounded"
          >
            {tag.name}
          </span>
          {/each}
        </div>
      </div>
    </div>
    {/each} {/if}
  </div>

  <!-- Right Column for Charts -->
  <div class="hidden md:block md:w-1/3 px-2 py-4 resize overflow-auto">
    <div
      class="bg-[#041014] rounded-lg shadow p-4 hover:bg-black border border-black hover:border-[#145369]"
    >
      <h2 class="text-xl font-semibold mb-4">Charts</h2>
      <Charts {coins} {form} />
    </div>
  </div>
</div>
```

To select 10 unique coins every 10 seconds, we randomly get them from the `coins` data and use `Set` to ensure no duplication is permitted. This is what `selectCoins` is about. As the DOM gets loaded, we call this function and then use `setInterval` for the periodic and automatic selection. We also ensure the interval is destroyed when we navigate out of the page for memory safety reasons.

For the charts, there is a component, `Charts`, that handles the logic:

```html
<!-- frontend/src/lib/components/Charts.svelte -->
<script>
	import { applyAction, enhance } from '$app/forms';
	import { notification } from '$lib/stores/notification.store';
	import ShowError from './ShowError.svelte';
	import Loader from './Loader.svelte';
	import { fly } from 'svelte/transition';
	import { onMount } from 'svelte';
	import Chart from 'chart.js/auto';
	import 'chartjs-adapter-moment';
	import { chartConfig, handleZoom } from '$lib/utils/helpers';
	import TagCoin from './inputs/TagCoin.svelte';

	export let coins,
		/** @type {import('../../routes/$types').ActionData} */
		form;

	/** @type {HTMLInputElement} */
	let tagInput,
		/** @type {HTMLCanvasElement} */
		priceChartContainer,
		/** @type {HTMLCanvasElement} */
		marketCapChartContainer,
		/** @type {HTMLCanvasElement} */
		totalVolumeChartContainer,
		fetching = false,
		rendered = false,
		/**
		 * @typedef {Object} CryptoData
		 * @property {Array<Number>} prices - The price data
		 * @property {Array<Number>} market_caps - The market cap data
		 * @property {Array<Number>} total_volumes - The total volume data
		 */

		/**
		 * @typedef {Object.<String, CryptoData>} CryptoDataSet
		 */

		/** @type {CryptoDataSet} */
		plotData = {},
		/** @type {CanvasRenderingContext2D | null} */

		context,
		/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
		priceChart,
		/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
		marketCapChart,
		/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */
		totalVolumeChart,
		/** @type {CanvasRenderingContext2D|null} */
		priceContext,
		/** @type {CanvasRenderingContext2D|null} */
		marketCapContext,
		/** @type {CanvasRenderingContext2D|null} */
		totalVolumeContext;

	/** @type {import('../../routes/$types').SubmitFunction}*/
	const handleCoinDataFetch = async () => {
		fetching = true;
		return async ({ result }) => {
			fetching = false;
			if (result.type === 'success') {
				$notification = { message: 'Coin data fetched successfully', colorName: 'blue' };

				if (result.data) {
					plotData = result.data.marketData;
					await applyAction(result);
				}
			}
		};
	};

	onMount(() => {
		priceContext = priceChartContainer.getContext('2d');
		marketCapContext = marketCapChartContainer.getContext('2d');
		totalVolumeContext = totalVolumeChartContainer.getContext('2d');

		if (priceContext === null || marketCapContext === null || totalVolumeContext === null) {
			throw new Error('Could not get the context of the canvas element');
		}

		// Create a new configuration object for each chart
		const priceChartConfig = { ...chartConfig };
		priceChartConfig.data = { datasets: [] };
		priceChart = new Chart(priceContext, priceChartConfig);

		const marketCapChartConfig = { ...chartConfig };
		marketCapChartConfig.data = { datasets: [] };
		marketCapChart = new Chart(marketCapContext, marketCapChartConfig);

		const totalVolumeChartConfig = { ...chartConfig };
		totalVolumeChartConfig.data = { datasets: [] };
		totalVolumeChart = new Chart(totalVolumeContext, totalVolumeChartConfig);

		rendered = true;

		// Add event listeners for zooming
		priceChartContainer.addEventListener('wheel', (event) => handleZoom(event, priceChart));
		marketCapChartContainer.addEventListener('wheel', (event) => handleZoom(event, marketCapChart));
		totalVolumeChartContainer.addEventListener('wheel', (event) =>
			handleZoom(event, totalVolumeChart)
		);
	});

	/**
	 * Update the chart with new data
	 * @param {Chart<"line", { x: Date; y: number; }[], unknown>} chart - The chart to update
	 * @param {Array<Array<number>>} data - The new data to update the chart with
	 * @param {string} label - The label to use for the dataset
	 * @param {string} cryptoName - The name of the cryptocurrency
	 */
	const updateChart = (chart, data, label, cryptoName) => {
		const dataset = {
			label: `${cryptoName} ${label}`,
			data: data.map(
				/** @param {Array<number>} item */
				(item) => {
					return {
						x: new Date(item[0]),
						y: item[1]
					};
				}
			),
			fill: false,
			borderColor: '#' + Math.floor(Math.random() * 16777215).toString(16),
			tension: 0.1
		};

		chart.data.datasets.push(dataset);
		chart.update();
	};

	$: if (rendered) {
		// Clear the datasets for each chart
		priceChart.data.datasets = [];
		marketCapChart.data.datasets = [];
		totalVolumeChart.data.datasets = [];

		Object.keys(plotData).forEach(
			/** @param {string} cryptoName */
			(cryptoName) => {
				// Update each chart with the new data
				updateChart(priceChart, plotData[cryptoName].prices, 'Price', cryptoName);
				updateChart(marketCapChart, plotData[cryptoName].market_caps, 'Market Cap', cryptoName);
				updateChart(
					totalVolumeChart,
					plotData[cryptoName].total_volumes,
					'Total Volume',
					cryptoName
				);
			}
		);
	}
</script>

<form action="?/getCoinData" method="POST" use:enhance={handleCoinDataFetch}>
	<ShowError {form} />
	<div style="display: flex; justify-content: space-between;">
		<div style="flex: 2; margin-right: 10px;">
			<TagCoin
				label="Cryptocurrencies"
				id="tag-input"
				name="tags"
				value=""
				{coins}
				placeholder="Select cryptocurrencies..."
			/>
		</div>
		<div style="flex: 1; margin-left: 10px;">
			<label for="days" class="block text-[#efefef] text-sm font-bold mb-2">Days</label>
			<input
				type="number"
				id="days"
				name="days"
				value="7"
				required
				class="w-full p-4 bg-[#0a0a0a] text-[#efefef] border border-[#145369] rounded focus:outline-none focus:border-[#2596be] text-gray-500"
				placeholder="Enter days"
			/>
		</div>
	</div>
	{#if fetching}
		<Loader width={20} message="Fetching data..." />
	{:else}
		<button
			class="px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-[#efefef] hover:text-white rounded"
		>
			Fetch Coin Data
		</button>
	{/if}
</form>

<div in:fly={{ x: 100, duration: 1000, delay: 1000 }} out:fly={{ duration: 1000 }}>
	<canvas bind:this={priceChartContainer} />
	<canvas bind:this={marketCapChartContainer} />
	<canvas bind:this={totalVolumeChartContainer} />
</div>
```

We employed [Charts.js][3] as the charting library. It's largely simple to use. Though the component looks big, it's very straightforward. We used `JSDocs` instead of TypeScript for annotations. At first, when the DOM was mounted, we created charts with empty datasets. We then expect users to select their preferred coins and number of days. Clicking the `Fetch Coin Data` button will send the inputted data to the backend using SvelteKit's form actions. The data returned by this API call will be used to populate the plots using Svelte's reactive block dynamically. The code for the form action and the preliminary data retrieval from the backend is in `frontend/src/routes/+page.server.js`:

```js
import { BASE_API_URI } from "$lib/utils/constants";
import { fail } from "@sveltejs/kit";

/** @type {import('./$types').PageServerLoad} */
export async function load({ fetch }) {
  const fetchQuestions = async () => {
    const res = await fetch(`${BASE_API_URI}/qa/questions`);
    return res.ok && (await res.json());
  };

  const fetchCoins = async () => {
    const res = await fetch(`${BASE_API_URI}/crypto/coins`);
    return res.ok && (await res.json());
  };

  const questions = await fetchQuestions();
  const coins = await fetchCoins();

  return {
    questions,
    coins,
  };
}

// Get coin data form action

/** @type {import('./$types').Actions} */
export const actions = {
  /**
   * Get coin market history data from the API
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  getCoinData: async ({ request, fetch }) => {
    const data = await request.formData();
    const coinIDs = String(data.get("tags"));
    const days = Number(data.get("days"));
    const res = await fetch(
      `${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}&currency=USD&days=${days}`
    );
    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    const response = await res.json();

    return {
      status: 200,
      marketData: response,
    };
  },
};
```

The endpoint used here, `${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}&currency=USD&days=${days}`, was just created and the code is:

```rust
// backend/src/routes/crypto/prices.rs

use crate::{
    settings,
    utils::{CustomAppError, CustomAppJson},
};
use axum::extract::Query;
use std::collections::HashMap;

#[derive(serde::Deserialize, Debug)]
pub struct CoinMarketDataRequest {
    tags: String,
    currency: String,
    days: i32,
}

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct CoinMarketData {
    prices: Vec<Vec<f64>>,
    market_caps: Vec<Vec<f64>>,
    total_volumes: Vec<Vec<f64>>,
}

#[axum::debug_handler]
#[tracing::instrument(name = "get_coin_market_data")]
pub async fn get_coin_market_data(
    Query(coin_req): Query<CoinMarketDataRequest>,
) -> Result<CustomAppJson<HashMap<String, CoinMarketData>>, CustomAppError> {
    let tag_ids: Vec<String> = coin_req.tags.split(',').map(|s| s.to_string()).collect();
    let mut responses = HashMap::new();
    let settings = settings::get_settings().expect("Failed to get settings");
    for tag_id in tag_ids {
        let url = format!(
            "{}/coins/{}/market_chart?vs_currency={}&days={}",
            settings.coingecko.api_url, &tag_id, coin_req.currency, coin_req.days
        );
        match reqwest::get(&url).await {
            Ok(response) => match response.json::<CoinMarketData>().await {
                Ok(data) => {
                    responses.insert(tag_id, data);
                }
                Err(e) => {
                    tracing::error!("Failed to parse market data from response: {}", e);
                }
            },
            Err(e) => {
                tracing::error!("Failed to fetch market data from CoinGecko: {}", e);
            }
        }
    }

    Ok(CustomAppJson(responses))
}
```

It simply uses CoinGecko's API to retrieve the history data of the coins since `days` ago. Back to the frontend code, SvelteKit version 2 made some changes that mandate [explicitly awaiting asynchronous functions in `load`][4]. This and other changes will be pointed out as the series progresses. Our `load` fetches both the questions and coins from the backend. No pagination is implemented here but it's easy to implement with `sqlx`. Pagination can also be done easily with sveltekit. You can take that up as a challenge.

The `Charts.svelte` components used some custom input components. This is simply for modularity's sake and is just simple HTML elements with tailwind CSS. Also, it used `chartConfig` and `handleZoom`. The former is just a simple configuration for the entire charts while the latter just allows simple zoom in and out of the plots. For better zooming and panning features, it's recommended to use the [chartjs-plugin-zoom][5].

With all these in place, the landing page should look like this:

![Application's home page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eu29eza0of3o5gks2un5.jpeg "Application's home page")

### Step 2: Question Detail page

The middle column on the landing page shows all the questions in the database. We need a page that zooms in on each question so that other users can provide answers. We have such a page in `frontend/src/routes/questions/[id]/+page.svelte`:

```html
<script>
	import { applyAction, enhance } from '$app/forms';
	import { page } from '$app/stores';
	import Logo from '$lib/assets/logo.png';
	import {
		formatCoinName,
		formatPrice,
		getCoinsPricesServer,
		highlightCodeBlocks,
		timeAgo
	} from '$lib/utils/helpers.js';
	import { afterUpdate, onMount } from 'svelte';
	import Loader from '$lib/components/Loader.svelte';
	import { scale } from 'svelte/transition';
	import { flip } from 'svelte/animate';
	import Modal from '$lib/components/Modal.svelte';
	import hljs from 'highlight.js';
	import ShowError from '$lib/components/ShowError.svelte';
	import { notification } from '$lib/stores/notification.store.js';
	import 'highlight.js/styles/night-owl.css';
	import TextArea from '$lib/components/inputs/TextArea.svelte';

	export let data;

	/** @type {import('./$types').ActionData} */
	export let form;
	/** @type {Array<{"name": String, "price": number}>} */
	let coinPrices = [],
		processing = false,
		showDeleteModal = false,
		showEditModal = false,
		answerID = '',
		answerContent = '';

	$: ({ question, answers } = data);

	const openModal = (isDelete = true) => {
		if (isDelete) {
			showDeleteModal = true;
		} else {
			showEditModal = true;
		}
	};

	const closeModal = () => {
		showDeleteModal = false;
		showEditModal = false;
	};

	/** @param {String} id */
	const setAnswerID = (id) => (answerID = id);
	/** @param {String} content */
	const setAnswerContent = (content) => (answerContent = content);

	onMount(async () => {
		highlightCodeBlocks(hljs);
		if (question) {
			const tagsString = question.tags
				.map(
					/** @param {{"id": String}} tag */
					(tag) => tag.id
				)
				.join(',');
			coinPrices = await getCoinsPricesServer($page.data.fetch, tagsString, 'usd');
		}
	});

	afterUpdate(() => {
		highlightCodeBlocks(hljs);
	});

	/** @type {import('./$types').SubmitFunction} */
	const handleAnswerQuestion = async () => {
		processing = true;
		return async ({ result }) => {
			processing = false;
			if (result.type === 'success') {
				if (result.data && 'answer' in result.data) {
					answers = [result.data.answer, ...answers];
					answerContent = '';
					notification.set({ message: 'Answer posted successfully', colorName: 'blue' });
				}
			}
			await applyAction(result);
		};
	};

	/** @type {import('./$types').SubmitFunction} */
	const handleDeleteAnswer = async () => {
		return async ({ result }) => {
			closeModal();
			if (result.type === 'success') {
				answers = answers.filter(
					/** @param {{"id": String}} answer */
					(answer) => answer.id !== answerID
				);
				notification.set({ message: 'Answer deleted successfully', colorName: 'blue' });
			}
			await applyAction(result);
		};
	};

	/** @type {import('./$types').SubmitFunction} */
	const handleUpdateAnswer = async () => {
		return async ({ result }) => {
			closeModal();
			if (result.type === 'success') {
				answers = answers.map(
					/** @param {{"id": String}} answer */
					(answer) => {
						if (result.data && 'answer' in result.data) {
							return answer.id === answerID ? result.data.answer : answer;
						}
						return answer;
					}
				);
				answerContent = '';
				notification.set({ message: 'Answer updated successfully', colorName: 'blue' });
			}
			await applyAction(result);
		};
	};
</script>

<div class="max-w-5xl mx-auto p-4">
	<!-- Stats Section -->
	<div class="bg-[#0a0a0a] p-6 rounded-lg shadow mb-6 flex justify-between items-center">
		<p>Asked: {timeAgo(question.created_at)}</p>
		<p>Modified: {timeAgo(question.updated_at)}</p>
	</div>
	<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
		<!-- Main Content -->
		<div class="md:col-span-9">
			<!-- Question Section -->
			<div class="bg-[#041014] p-6 rounded-lg shadow mb-6 border border-black">
				<h1 class="text-2xl font-bold mb-4">{question.title}</h1>
				<p>{@html question.content}</p>
				<div class="flex mt-4 flex-wrap">
					{#each question.tags as tag}
						<span
							class="mr-2 mb-2 px-3 py-1 text-sm bg-[#041014] border border-[#145369] hover:border-[#2596be] rounded"
						>
							{tag.name.toLowerCase()}
						</span>
					{/each}
				</div>
				<div class="flex justify-end mt-4">
					{#if $page.data.user && question.author.id === $page.data.user.id}
						<a
							class="mr-2 text-blue-500 hover:text-blue-600"
							href="/questions/{question.id}/update"
						>
							Edit
						</a>
						<a class="mr-2 text-red-500 hover:text-red-600" href="/questions/{question.id}/delete">
							Delete
						</a>
					{/if}
				</div>
				<hr class="my-4" />
				<div class="flex justify-end items-center">
					<span class="mr-3">
						{question.author.first_name + ' ' + question.author.last_name}
					</span>
					<img
						src={question.author.thumbnail ? question.author.thumbnail : Logo}
						alt={question.author.first_name + ' ' + question.author.last_name}
						class="h-10 w-10 rounded-full"
					/>
				</div>
			</div>

			<!-- Answers Section -->
			<h2 class="text-xl font-bold mb-4">Answers</h2>
			{#each answers as answer (answer.id)}
				<div
					class="bg-[#041014] p-6 rounded-lg shadow mb-4"
					transition:scale|local={{ start: 0.4 }}
					animate:flip={{ duration: 200 }}
				>
					<p>{@html answer.content}</p>

					<div class="flex justify-end mt-4">
						{#if $page.data.user && answer.author.id === $page.data.user.id}
							<button
								class="mr-2 text-blue-500 hover:text-blue-600"
								on:click={() => {
									openModal(false);
									setAnswerID(answer.id);
									setAnswerContent(answer.raw_content);
								}}
							>
								Edit
							</button>
							<button
								class="mr-2 text-red-500 hover:text-red-600"
								on:click={() => {
									openModal();
									setAnswerID(answer.id);
								}}
							>
								Delete
							</button>
						{/if}
					</div>
					<hr class="my-4" />
					<div class="flex justify-end items-center">
						<span class="mr-3">{answer.author.first_name + ' ' + answer.author.last_name}</span>
						<img
							src={answer.author.thumbnail ? answer.author.thumbnail : Logo}
							alt={answer.author.first_name + ' ' + answer.author.last_name}
							class="h-10 w-10 rounded-full"
						/>
					</div>
				</div>
			{:else}
				<div class="bg-[#041014] p-6 rounded-lg shadow mb-4">
					<p>No answers yet.</p>
				</div>
			{/each}

			<!-- Post Answer Section -->
			<form
				class="bg-[#041014] p-6 rounded-lg shadow"
				method="POST"
				action="?/answer"
				use:enhance={handleAnswerQuestion}
			>
				<h2 class="text-xl font-bold mb-4">Your Answer</h2>
				<ShowError {form} />

				<TextArea
					label=""
					id="answer"
					name="content"
					placeholder="Write your answer here (markdown supported)..."
					bind:value={answerContent}
				/>

				{#if processing}
					<Loader width={20} message="Posting your answer..." />
				{:else}
					<button
						class="mt-4 px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-white rounded"
					>
						{#if $page.data.user && $page.data.user.id === question.author.id}
							Answer your question
						{:else}
							Post Your Answer
						{/if}
					</button>
				{/if}
			</form>
		</div>

		<!-- Right Sidebar -->
		<div class="md:col-span-3">
			<h2 class="text-xl font-semibold mb-4">Current prices</h2>
			<div
				class="bg-[#041014] rounded-lg shadow p-4 hover:bg-black border border-black hover:border-[#145369]"
			>
				<div class="space-y-4">
					{#each coinPrices as coin (coin.name)}
						<div
							class="bg-[#145369] p-3 rounded-lg text-center"
							transition:scale|local={{ start: 0.4 }}
							animate:flip={{ duration: 200 }}
						>
							<p class="text-3xl font-bold">
								<span class="text-base">$</span>{formatPrice(coin.price)}
							</p>
							{#if question.tags.find(/** @param {{"id": String}} tag */ (tag) => tag.id === coin.name)}
								<div class="flex items-center text-lg">
									<img
										class="w-8 h-8 rounded-full mr-2 transition-transform duration-500 ease-in-out transform hover:rotate-180"
										src={question.tags.find(
											/** @param {{"id": String}} tag */
											(tag) => tag.id === coin.name
										).image}
										alt={coin.name}
									/>
									<span class="mr-2">
										{formatCoinName(
											coin.name,
											question.tags.find(
												/** @param {{"id": String}} tag */
												(tag) => tag.id === coin.name
											).symbol
										)}
									</span>
								</div>
							{/if}
						</div>
					{/each}
				</div>
			</div>
		</div>
	</div>
</div>

{#if showDeleteModal}
	<Modal on:close={closeModal}>
		<form
			class="bg-[#041014] p-6 rounded-lg shadow text-center"
			method="POST"
			action="?/deleteAnswer"
			use:enhance={handleDeleteAnswer}
		>
			<ShowError {form} />
			<p class="text-red-500 p-3 mb-4 italic">
				Are you sure you want to delete this answer (id={answerID})
			</p>
			<input type="hidden" name="answerID" value={answerID} />
			<button
				class="mt-4 px-6 py-2 bg-[#041014] border border-red-400 hover:border-red-700 text-red-600 rounded"
			>
				Delete Answer
			</button>
		</form>
	</Modal>
{/if}

{#if showEditModal}
	<Modal on:close={closeModal}>
		<form
			class="bg-[#041014] p-6 rounded-lg shadow text-center"
			method="POST"
			action="?/updateAnswer"
			use:enhance={handleUpdateAnswer}
		>
			<ShowError {form} />
			<input type="hidden" name="answerID" value={answerID} />
			<textarea
				class="w-full p-4 bg-[#0a0a0a] text-[#efefef] border border-[#145369] rounded focus:border-[#2596be] focus:outline-none"
				rows="6"
				bind:value={answerContent}
				name="content"
				placeholder="Write your answer here (markdown supported)..."
			/>
			<button
				class="mt-4 px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-white rounded"
			>
				Update Answer
			</button>
		</form>
	</Modal>
{/if}
```

It has two columns:

1. The first shows the question and all the answers to that question.
2. The second shows the current price of the coin tagged in the question. The prices do not get updated live or in real time, you need to refresh the page for updated prices but this can be improved using web sockets.

This page has an accompanying `+page.server.js` that fetches the data the page uses and handles other subsequent interactions such as posting, updating, and deleting answers:

```js
import { BASE_API_URI } from "$lib/utils/constants";
import { fail } from "@sveltejs/kit";

/** @type {import('./$types').PageServerLoad} */
export async function load({ fetch, params }) {
  const fetchQuestion = async () => {
    const res = await fetch(`${BASE_API_URI}/qa/questions/${params.id}`);
    return res.ok && (await res.json());
  };

  const fetchAnswers = async () => {
    const res = await fetch(
      `${BASE_API_URI}/qa/questions/${params.id}/answers`
    );
    return res.ok && (await res.json());
  };

  return {
    question: await fetchQuestion(),
    answers: await fetchAnswers(),
  };
}

/** @type {import('./$types').Actions} */
export const actions = {
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  answer: async ({ request, fetch, params, cookies }) => {
    const data = await request.formData();
    const content = String(data.get("content"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
      body: JSON.stringify({
        content: content,
      }),
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answer/${params.id}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    const response = await res.json();

    return {
      status: 200,
      answer: response,
    };
  },
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  deleteAnswer: async ({ request, fetch, cookies }) => {
    const data = await request.formData();
    const answerID = String(data.get("answerID"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "DELETE",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answers/${answerID}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    return {
      status: res.status,
    };
  },
  /**
   *
   * @param request - The request object
   * @param fetch - Fetch object from sveltekit
   * @returns Error data or redirects user to the home page or the previous page
   */
  updateAnswer: async ({ request, fetch, cookies }) => {
    const data = await request.formData();
    const answerID = String(data.get("answerID"));
    const content = String(data.get("content"));

    /** @type {RequestInit} */
    const requestInitOptions = {
      method: "PATCH",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Cookie: `sessionid=${cookies.get("cryptoflow-sessionid")}`,
      },
      body: JSON.stringify({
        content: content,
      }),
    };

    const res = await fetch(
      `${BASE_API_URI}/qa/answers/${answerID}`,
      requestInitOptions
    );

    if (!res.ok) {
      const response = await res.json();
      const errors = [{ id: 1, message: response.message }];
      return fail(400, { errors: errors });
    }

    return {
      status: res.status,
      answer: await res.json(),
    };
  },
};
```

It's just the familiar structure with a `load` function and a bunch of other form actions. Since all the other pages have this structure, I will skip explaining them but will include their screenshots

You can follow along by reading through the code on GitHub. They are very easy to follow.

The question detail page looks like this:

![Question detailed page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fl2qbcb85lll1uhnw7jb.jpeg "Question detailed page")

As for login and signup pages, we have these:

![Login page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/41wxzvvmu9mtewnpakwp.jpeg "Login page")

![Signup page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yug58aawe2jr58qw2r4r.jpeg "Signup page")

When one registers, a one-time token is sent to the user's email. There's a page to input this token and get the account attached to it activated. The page looks like this:

![Activate account page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h6d9mc1utd15s05yw40m.jpeg "Activate account page")

With that, we end this series. Kindly check the series' GitHub repository for the updated and complete code. They are intuitive.

I apologize once again for the abandonment.

## Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and Twitter: [Twitter](https://twitter.com/Sirneij).

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

[1]: https://cryptoflow-one.vercel.app/ "CryptoFlow live application"
[2]: https://docs.render.com/free "Deploy for Free"
[3]: https://www.chartjs.org/docs/latest/ "Chart.js"
[4]: https://kit.svelte.dev/docs/migrating-to-sveltekit-2#top-level-promises-are-no-longer-awaited "Top-level promises are no longer awaited"
[5]: https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/ "A zoom and pan plugin for Chart.js >= 3.0.0"