Skip to main content

Sveltekit-naaraan

This tutorial will help you understand about:

  • naaraan: a mono repository using pnpm workspace that holds all frontend services in API PLUS TECH.
  • FoundationAPI: API PLUS TECH internal GraphQL API to mutate and query data from a database. You can read more about GraphQL for Front End Developers here)
  • Tilt: a toolkit for microservice development. You can read more about Tilt here.

What we're going to build

We are going to create 2 new pages in the storefront admin

  1. Product list page: a page to display a list of the first 50 products with SSR in /admin/products route.

  2. Product CRUD page: a page to create, display product details (SSR), update, and delete following these conditions:

    • if the URL is /admin/products/new (product_id = "new"), this page can only create a product.
    • if the URL is /admin/products/[product_id], this page can read, update and delete a product.

with these features:

  • Theme switching: change between light and dark theme
  • Language switching: change between EN and TH
  • Responsive with desktop, tablet and mobile layouts

by following these the Figma design

You can get a github starter project here.

Project structure

.
├── config.yaml ---> config file for transtaltion generator
├── Makefile ---> make file for run the transtaltion generator commands
├── apps
│ ├── account ---> authentication and business-unit management website
│ ├── hq ---> landing page website for naaraanhq
│ └── storefront ---> e-commerce and admin website
├── docker ---> all project dockerfile
├── packages
│ └── shared ---> shared code between apps
│ ├── assets ---> static assets ex. fonts
│ ├── components ---> shared components
│ ├── configs ---> config files ex. tailwind.config.cjs
│ ├── constants ---> all constants
│ ├── data
│ │ ├── functions ---> store all api functions
│ │ ├── models ---> store all api types
│ │ └── sources ---> store all api clients
│ ├── helpers
│ ├── stores ---> global svelte stores ex.env, theme
│ ├── styles ---> global styles ex. app.postcss
│ ├── translations ---> all constants
│ │ ├── i18n.ts ---> translation configs
│ │ ├── app ---> all translation for all apps
│ │ │ ├── app_keys.csv ---> translation key value of all languages for app
│ │ │ ├── config.yaml ---> translation configs for app
│ │ │ └── langs ---> generated json from csv
│ │ ├── error ---> all translation for all errors
│ │ └── generated ---> generated translation enum
│ └── utils
└── tilt ---> configs and script for developing microservices

Project setup

  1. Setup the Github pre-commit to format code before commit

    make setup
  2. Install project dependencies from this command

    pnpm i
  3. Install the gql-python to autogenerate GraphQL function types

    pip install gql[all]

    Note: for mac please use this command instead

    pip install gql\[all\]

Start all microservices with Tilt

  1. Go to the tilt folder

    cd tilt
  2. Run Docker login as Apiplus-bot to pull backend services image from the Github private container registry.

    echo <token-from-apiplus-bot> | docker login ghcr.io -u apiplus-bot --password-stdin
  3. Run setup script

    make setup
  4. Start all microservices

    make up
  5. Go to http://localhost:10350 to check that all services are ready

    These are all service URLs:

    Back-end:

   - FOUNDATION_PUBLIC_URL = <https://foundation.naaraanhq.dev/query>
- FOUNDATION_ADMIN_URL = <https://foundation-admin.naaraanhq.dev/query>
- REST_FOUNDATION_PUBLIC_URL = <https://foundation.naaraanhq.dev>
- KRATOS_PUBLIC_URL = <https://kratos.naaraanhq.dev>
- HYDRA_PUBLIC_URL = <https://hydra.naaraanhq.dev>
- AUTHEN_PUBLIC_URL = <https://authen.naaraanhq.dev/graphql>

Front-end:

   - Hq = <https://www.naaraanhq.dev>
- Account = <https://account.naaraanhq.dev>
- Storefront = https://**business-unit-name**.naaraan.dev

Create NAARAAN account

  1. Go to https://account.naaraanhq.dev.

  2. Click the Sign up button to go to /sign_up route.

  3. Enter test user information and press the Sign up button

    Email: test@test.com
    Password: Secured112233445

Create Business-Unit

  1. Click Create new business

  2. Enter business unit information

    Store Name: test
  3. After clicking Confirm, the website will redirect to https://test.naaraan.dev/admin

Add typography

  1. Open this Figma Design system for typography and color scheme.

  2. Look at fontSize field inside theme field in tailwind.config.cjs.

     fontSize: {
    h0: ['4rem', '6rem'],
    h1: ['2.25rem', '3.375rem'],
    h2: ['1.5rem', '2.25rem'],
    h3: ['1.25rem', '1.875rem'],
    label: ['1rem', '1.5rem'],
    body: ['0.875rem', '1.25rem'],
    caption: ['0.75rem', '1rem']
    },

Add theme switching

  1. Open this Figma Design system for typography and color scheme.

  2. Add a CSS variable of light and dark theme colors from the color scheme to packages/shared/src/lib/styles/app.postcss.

    .theme-light {
    --primary: #435bc2;
    --primaryVariant: #3542b7;
    --primaryOpacity: #435bc21a;
    --primaryOpacityFlatten: #eceff9;
    --primaryOpacity2: #e2eafc;
    --secondary: #ffffff;
    --secondaryVariant: #f3f3f3;
    --secondaryOpacity: #3231300d;
    --background: #ffffff;
    --backgroundHq: #181755;
    --backgroundVariant: #f0f1f6;
    --error: #fd706b;
    --errorOpacity: #b80f0a1a;
    --success: #57c954;
    --successOpacity: #48bf5340;
    --disable: #f6f6f6;
    --disableOpacity: #dddddd40;
    --disableOpacityFlatten: #f7f7f7;
    --inProgress: #ffb800;
    --inProgressOpacity: #ffb80040;
    --textNormal: #202223;
    --textDisable: #bbbbbc;
    --textHint: #8594a6;
    --textAccent: #3d51bb;
    --textWarning: #f0635e;
    --textOnSurface: #ffffff;
    --stroke: #e0e0e0;
    --lightStroke: #ececec;
    --modalBackground: #c4c4c473;
    --promptPayBackground: #00427a;
    --gray: #eaeaea;
    --black: #000000;
    --nightBackground: #2f3135;
    --navIcon: #949eb4;
    }

    .theme-dark {
    --primary: #6893ef;
    --primaryVariant: #658ee7;
    --primaryOpacity: #435bc21a;
    --primaryOpacityFlatten: #eceff9;
    --primaryOpacity2: #e2eafc;
    --secondary: #ffffff;
    --secondaryVariant: #f3f3f3;
    --secondaryOpacity: #3231300d;
    --background: #2f3135;
    --backgroundHq: #181755;
    --backgroundVariant: #f0f1f6;
    --error: #fd706b;
    --errorOpacity: #b80f0a1a;
    --success: #57c954;
    --successOpacity: #48bf5340;
    --disable: #f6f6f6;
    --disableOpacity: #dddddd40;
    --disableOpacityFlatten: #f7f7f7;
    --inProgress: #ffb800;
    --inProgressOpacity: #ffb80040;
    --textNormal: #ffffff;
    --textDisable: #bbbbbc;
    --textHint: #8594a6;
    --textAccent: #3d51bb;
    --textWarning: #f0635e;
    --textOnSurface: #ffffff;
    --stroke: #e0e0e0;
    --lightStroke: #ececec;
    --modalBackground: #c4c4c473;
    --promptPayBackground: #00427a;
    --gray: #eaeaea;
    --black: #000000;
    --nightBackground: #2f3135;
    --navIcon: #949eb4;
    }
  3. Look at custom colors inside theme field in packages/shared/src/lib/configs/tailwind.config.cjs.

     colors: {
    current: 'currentColor',
    primary: 'var(--primary)',
    primaryVariant: 'var(--primaryVariant)',
    primaryOpacity: 'var(--primaryOpacity)',
    primaryOpacityFlatten: 'var(--primaryOpacityFlatten)',
    primaryOpacity2: 'var(--primaryOpacity2)',
    secondary: 'var(--secondary)',
    secondaryVariant: 'var(--secondaryVariant)',
    secondaryOpacity: 'var(--secondaryOpacity)',
    background: 'var(--background)',
    backgroundHq: 'var(--backgroundHq)',
    backgroundVariant: 'var(--backgroundVariant)',
    error: 'var(--error)',
    errorOpacity: 'var(--errorOpacity)',
    success: 'var(--success)',
    successOpacity: 'var(--successOpacity)',
    disable: 'var(--disable)',
    disableOpacity: 'var(--disableOpacity)',
    disableOpacityFlatten: 'var(--disableOpacityFlatten)',
    inProgress: 'var(--inProgress)',
    inProgressOpacity: 'var(--inProgressOpacity)',
    textNormal: 'var(--textNormal)',
    textDisable: 'var(--textDisable)',
    textHint: 'var(--textHint)',
    textAccent: 'var(--textAccent)',
    textWarning: 'var(--textWarning)',
    textOnSurface: 'var(--textOnSurface)',
    stroke: 'var(--stroke)',
    lightStroke: 'var(--lightStroke)',
    modalBackground: 'var(--modalBackground)',
    promptPayBackground: 'var(--promptPayBackground)',
    gray: 'var(--gray)',
    black: 'var(--black)',
    nightBackground: 'var(--nightBackground)',
    navIcon: 'var(--navIcon)'
    },
  4. Create theme.ts inside package/shared/src/lib/stores for switching between light theme and dark theme.

    import { writable } from 'svelte/store'

    export const isDarkMode = writable<boolean>(false)
  5. You can implement theme switching by adding theme-dark and theme-light CSS variables to +layout.svelte

    <script lang="ts">
    import { isDarkMode } from '$shared_lib/stores/theme';
    import '$shared_lib/styles/app.postcss';
    </script>

    <div class={$isDarkMode ? 'theme-dark' : 'theme-light'}>
    <slot />
    </div>
  6. To validate theme switching, we create the theme toggle button to change the background color in /admin.

    <script lang="ts">
    .
    .
    function onClickToggleTheme() {
    isDarkMode.set(!$isDarkMode);
    }
    .
    .
    </script>
    .
    .
    <Button class="!w-full" on:click={onClickToggleTheme}>
    {#if $isDarkMode} {$t(LocaleKeys.t_dark)} {:else} {$t(LocaleKeys.t_light)} {/if}
    </Button>
    .
    .

    light_theme

    dark_theme

Add language switching

  1. We are using sveltekit-i18n package for language switching.

    In the starter project, there are 2 CSV files:

    1. packages/shared/src/lib/translations/app/app_keys.csv to translate all texts in the app.
    2. packages/shared/src/lib/translations/app/app_keys.csv to translate all error messages in the app.
  2. You can add your keys for translation by adding row in app_keys.csv files.

    "products","Products","สินค้า"
    "light","Light","สว่าง"
    "dark","Dark","มืด"
    "th","TH","ไทย"
    "en","EN","อังกฤษ"
  3. These CSV files will be used for generating to

    • en.json
    • th.json
    • error_status_keys_gen.ts
    • locale_keys_gen.ts

    by running the following command:

    make gen_translation
  4. Add the following packages/shared/src/lib/translations/i18n.ts to configurate translation files path for svelte-i18n

    import i18n from 'sveltekit-i18n'
    const config = {
    loaders: [
    {
    locale: 'en',
    key: '',
    loader: async () => (await import('./app/langs/en. json')).default,
    },
    {
    locale: 'th',
    key: '',
    loader: async () => (await import('./app/langs/th. json')).default,
    },
    {
    locale: 'en',
    key: '',
    loader: async () => (await import('./error/langs/en. json')).default,
    },
    {
    locale: 'th',
    key: '',
    loader: async () => (await import('./error/langs/th. json')).default,
    },
    ],
    }
    export const { t, locale, locales, loading, loadTranslations } = new i18n(
    config
    )
  5. You can implement language switching by adding init i18n functions inside the LayoutLoad function of the +layout.ts.

    const defaultLocale = 'en'
    const initLocale = locale.get() || defaultLocale
    await loadTranslations(initLocale)
  6. To validate language switching, we create the language toggle button to change the language.

    <script lang="ts">
    .
    .
    function onClickToggleLanguage() {
    if ($locale === "en") locale.set("th");
    else locale.set("en");
    }
    .
    .
    </script>

    <Button
    class="!w-full"
    variant={ButtonVariants.secondaryOutline}
    on:click={onClickToggleLanguage}
    >
    {#if $locale === 'en'}
    {$t(LocaleKeys.t_en)}
    {:else}
    {$t(LocaleKeys.t_th)}
    {/if}
    </Button>

    sveltekit_product_en

    sveltekit_product_th

Add responsive layouts

In this tutorial, we will support 3 layouts consist of:

  • mobile (width < 420px )
  • tablet (420px < width < 960px)
  • desktop (width > 960px)

By default, TailwindCSS uses a mobile-first breakpoint system. Use unprefixed utilities to target mobile, and override them at larger breakpoints. You can read more about how to config responsive design in TailwindCSS here.

  1. You can look at a custom breakpoint in the screens field in the theme field inside tailwind.config.cjs.

       screens: {
    tablet: '420px',
    desktop: '960px'
    },
  2. Change code in /admin/product/+page.svelte to the following:

    <div
    class="flex h-full w-full items-center justify-center bg-error tablet:bg-success desktop:bg-background"
    >
    . . .
    </div>
  3. To validate responsive layouts, we will change the screen size to change the background.

    sveltekit_product_mobile

    sveltekit_product_tablet

    sveltekit_product_desktop

Create and List products using GraphiQL

To call FoundationAdminAPI using GraphiQL, you can open your browser then go to FoundationAdminAPI URL at https://foundation-admin.naaraanhq.dev and follow this GraphiQL tutorial using Business-Unit Id and AccessToken from https://test.naaraan.dev/admin.

Query products in /admin/products page (SSR)

  1. Create /admin/products page

  2. Create a _gql folder with the following files in the products folder to store all GraphQL functions on this page.

    .
    ├── admin
    │ └── products
    │ ├── +page.svelte
    │ ├── +page.ts
    │ └── _gql
    │ ├── foundation_admin.generated.ts
    │ ├── foundation_admin.graphql
    │ └── foundation_admin.ts
  3. Construct products query in GraphiQL which has query fields as below

    • edges.node.id
    • edges.node.title
    • edges.node.isDisplayed
    • edges.node.items.inventories.qtyAvailable
    • userErrors.errorCode
    • userErrors.message
    • userErrors.field

    and copy a products query and paste it on foundation_admin.graphql

  4. Run the following command to generate input types, response types and GQL document from foundation_admin.graphql to foundation_admin.generated.ts

    cd /apps/storefront
    make gen_gql_all
  5. Create queryProducts function to init FoundationAdmin GraphQL client, call API and handle error in foundation_admin.ts

    Note: You can use the VSCode snippet as a shortcut for creating this function by typing these keywords gqlq for GraphQL query and gqlm for GraphQL mutation.

    import {
    ProductConnection,
    ProductSortKey,
    } from '$shared_lib/data/graphql/model/foundation_admin'
    import { createFoundationAdminClient } from '$shared_lib/data/sources/client_foundation_admin'
    import {
    handlingCombinedError,
    handlingGenericeError,
    handlingUserErrorGen,
    } from '$shared_lib/utils/error'
    import { ProductsDocument } from './foundation_admin.generated'

    export async function queryProducts(args: {
    fetch?: any
    businessUnitId?: string
    accessToken?: string
    first?: number
    last?: number
    before?: string
    after?: string
    sort?: ProductSortKey
    query?: string
    }): Promise<ProductConnection> {
    let data: ProductConnection = <ProductConnection>{}
    const client = createFoundationAdminClient({
    fetch: args.fetch,
    accessToken: args.accessToken,
    businessUnitId: args.businessUnitId,
    })
    await client
    .query(ProductsDocument, {
    first: args.first,
    last: args.last,
    before: args.before,
    after: args.after,
    sort: args.sort,
    query: args.query,
    })
    .toPromise()
    .then((result) => {
    if (result.data) {
    if ((result.data.products.userErrors ?? []).length != 0) {
    handlingUserErrorGen(result.data.products.userErrors)
    }
    data = result.data.products as ProductConnection
    if (result.error) {
    handlingCombinedError(result.error)
    }
    }
    })
    .catch(function (error: any) {
    handlingGenericeError()
    })
    return data
    }
  6. Call queryProducts in +page.ts to query the list of products in SSR and pass the error and the productConnection to the page.

    import { ProductConnection } from '$shared_lib/data/models/graphqlfoundation_admin';
    import { AppError } from '$shared_lib/utils/error';
    import type { PageLoad } from './$types';
    import { queryProducts } from './\_gql/foundation_admin';

    export const load: PageLoad = async ({ fetch, parent }) => {
    let error: AppError | undefined;
    let productConnection: ProductConnection | undefined;
    // wait until LayoutLoad in +layout.ts has finished
    // to get accessToken and businessUnitId from parentData
    const parentData = await parent();
    try {
    productConnection = await queryProducts({
    // Don't forget to parse fetch function from PageLoad in SSR.
    // If not parse fetch function here, your products will be
    // fetched in CSR instead.
    fetch: fetch,
    accessToken: parentData?.accessToken,
    businessUnitId: parentData?.app?.businessUnitId
    });
    } catch (e) {
    if (e instanceof AppError) {
    error = e;
    }
    }
    return {
    error: error,
    productConnection: productConnection
    };
    };
  7. Add error handling and display product data in +page.svelte.

        <script lang="ts">
    import { addToast, ToastVariants } from '$shared_lib/components/toast/toast';
    import { toastRemoveDuration } from '$shared_lib/constants/app_constants';
    import { t } from '$shared_lib/translations/i18n';
    import { AppError } from '$shared_lib/utils/error';
    import type { PageData } from './$types';

    export let data: PageData;

    let error: AppError | undefined = data.error;

    // show ErrorToast when has some errors
    $: if (error) {
    addToast($t(error.errorCode), toastRemoveDuration, ToastVariants.error);
    }

    </script>

    {#if data?.productConnection?.edges}
    {#each data?.productConnection?.edges as productEdge}
    <h2>{productEdge.node.title}</h2>
    {/each}
    {/if}
  8. Validate your SSR products query result by opening the Network tab in Developer tools.

    • If your products document has data in the preview tab like the first picture that means data has been fetched in SSR.
    • If there is no any products data like the second picture that means your data has been fetched in CSR (Maybe forgot to parse fetch from PageLoad to queryProducts).

    validate_ssr_products validate_csr_products

Create a product in /admin/products/[product_id] page

  1. Create [product_id] folder nested below the products folder and add _gql folder like steps 1 and 2 in the last section.

    .
    ├── admin
    │ └── products
    │ ├── +page.svelte
    │ ├── +page.ts
    │ ├── _gql
    │ └── [product_id]
    │ ├── +page.svelte
    │ ├── +page.ts
    │ └── _gql
    │ ├── foundation_admin.generated.ts
    │ ├── foundation_admin.graphql
    │ └── foundation_admin.ts

  2. Create productMutation in GraphiQL and paste on foundation_admin.graphql then run

    cd /apps/storefront
    make gen_gql_all
  3. Create mutationProductCreate function in foundation_admin.ts

    export async function mutationProductCreate(args: {
    accessToken?: string;
    businessUnitId?: string;
    input: ProductInput;
    }): Promise<ProductResult> {
    let data: ProductResult = <ProductResult>{};
    const client = createFoundationAdminClient({
    accessToken: args.accessToken,
    businessUnitId: args.businessUnitId
    });
    await client
    .mutation(ProductCreateDocument, { input: args.input })
    .toPromise()
    .then((result) => {
    if (result.data) {
    if ((result.data.productCreate.userErrors ?? []).length != 0) {
    handlingUserErrorGen(result.data.productCreate.userErrors);
    }
    data = result.data.productCreate as ProductResult;
    if (result.error) {
    handlingCombinedError(result.error);
    }
    }
    })
    .catch(function (error: any) {
    handlingGenericeError();
    });
    return data;
    }
  4. Add the below code to /products/[product_id]/+page.svelte then try to create a new product.

    <script lang="ts">
    import { goto } from '$app/navigation';
    import { Button } from '$shared_lib/components/buttons/button';
    import { addToast, ToastVariants } from '$shared_lib/components/ toast/toast';
    import { toastRemoveDuration } from '$shared_lib/constants/ app_constants';
    import { ItemClass, ProductInput } from '$shared_lib/data/models/ graphql/foundation_admin';
    import { session } from '$shared_lib/stores/session';
    import LocaleKeys from '$shared_lib/translations/generated/ locale_keys_gen';
    import { t } from '$shared_lib/translations/i18n';
    import { AppError } from '$shared_lib/utils/error';
    import type { PageData } from './$types';
    import { mutationProductCreate } from './_gql/ foundation_admin';
    import { v4 as uuidv4 } from 'uuid';
    export let data: PageData;
    let error: AppError | undefined = data.error;
    let newProductInput: ProductInput = {
    title: 'new product ' + uuidv4(),
    items: [
    {
    title: 'new product ' + uuidv4(),
    itemClass: ItemClass.Sales,
    inventories: [
    {
    qtyAvailable: 10
    }
    ],
    prices: [
    {
    priceAmount: '100'
    }
    ]
    }
    ]
    };
    async function productCreate(productInput: ProductInput) {
    try {
    let productResult = await mutationProductCreate({
    businessUnitId: $session?.app?.businessUnitId,
    accessToken: $session?.accessToken,
    input: productInput
    });
    if (productResult) {
    goto('/admin/products');
    }
    } catch (e) {
    if (e instanceof AppError) {
    error = e;
    }
    }
    }
    // show ErrorToast when has some errors
    $: if (error) {
    addToast($t(error.errorCode), toastRemoveDuration, ToastVariants.error);
    }
    </script>

    <div class="h-full flex flex-col gap-4 items-cente justify-center">
    <pre>{JSON.stringify(newProductInput, null, 2)}</pre>
    <Button on:click={() => productCreate(newProductInput)}>
    {$t(LocaleKeys.t_create)}
    </Button>
    </div>

    products_new

  5. If your product has been successfully created, it should be listed on /products page

    products_after_create

Assignment

After we have learned all the base parts of this project, your task is to build the rest of this app from this design sveltekit-products design by using the following FoundationAdminAPI that you can search for these API documents in GraphiQL:

Query

  • product: to fetch product by id

Mutation

  • productUpdate: to update an existing product must send updated product information with the id
  • productDelete: to delete an existing product by id