# My qualifications

> The signed-in member's certificates and licences with their expiry, and the required ones they still lack.

- Block: [qualifications](https://bettersupabase.com/blocks/qualifications.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/my-qualifications

## Install the block

```bash
npx better-supabase add qualifications
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

From `apps/examples/nextjs/src/features/qualifications/components/my-qualifications.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/qualifications/components/my-qualifications.tsx):

```tsx
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

const STATUS_VARIANT = {
  valid: "success-light",
  expiring: "warning-light",
  expired: "destructive-light",
  revoked: "secondary",
} as const satisfies Record<QualificationStatus, string>;

/**
 * Render inside `<Suspense>`: the signed-in member's certificates and licences
 * with their expiry, then the required ones they still lack. Needs no
 * permission; members read their own holdings and gaps.
 */
export async function MyQualifications() {
  const [state, t, format] = await Promise.all([
    getMyQualifications(),
    getExtracted("qualifications"),
    getFormatter(),
  ]);

  if (state.kind === "no-organization") {
    return null;
  }

  if (state.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="my-qualifications">
        <TriangleAlertIcon />
        <AlertTitle>{t("Your qualifications didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { holdings, gaps } = state.data;

  const label = (status: QualificationStatus) => {
    switch (status) {
      case "valid": {
        return t("Valid");
      }

      case "expiring": {
        return t("Expiring");
      }

      case "expired": {
        return t("Expired");
      }

      case "revoked": {
        return t("Revoked");
      }

      default: {
        const unknown: never = status;

        return unknown;
      }
    }
  };

  const day = (date: string) =>
    format.dateTime(new Date(date), { dateStyle: "medium", timeZone: "UTC" });

  return (
    <Card data-recipe="my-qualifications">
      <CardHeader>
        <CardTitle>{t("My qualifications")}</CardTitle>
        <CardDescription>
          {t(
            "The certificates and licences you hold, and the ones your role still requires.",
          )}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {holdings.length === 0 && gaps.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <IdCardIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing to show yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Qualifications appear here once an admin grants one or you complete a course that awards one.",
                )}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : null}
        {holdings.length > 0 ? (
          <ul className="flex flex-col gap-2">
            {holdings.map((holding) => (
              <li
                key={holding.id}
                className="flex flex-wrap items-center justify-between gap-2"
              >
                <div className="flex flex-col gap-1">
                  <span className="text-sm font-medium">
                    {holding.typeName}
                  </span>
                  <span className="text-xs text-muted-foreground">
                    {holding.expiresOn
                      ? t("Expires {date}", { date: day(holding.expiresOn) })
                      : t("Never expires")}
                  </span>
                </div>
                <Badge variant={STATUS_VARIANT[holding.status]}>
                  {label(holding.status)}
                </Badge>
              </li>
            ))}
          </ul>
        ) : null}
        {gaps.length > 0 ? (
          <div className="flex flex-col gap-2">
            <span className="text-sm font-medium">{t("Still required")}</span>
            <div className="flex flex-wrap gap-2">
              {gaps.map((gap) => (
                <Badge key={gap.key} variant="destructive-light">
                  {gap.typeName}
                </Badge>
              ))}
            </div>
          </div>
        ) : null}
      </CardContent>
    </Card>
  );
}

export function MyQualificationsSkeleton() {
  return <Skeleton className="h-48" aria-busy="true" />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "My qualifications" in a React web app that uses shadcn/ui components. The signed-in member's certificates and licences with their expiry, and the required ones they still lack.

It uses the better-supabase `qualifications` block (Qualifications). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add qualifications` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card empty skeleton`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/qualifications/components/my-qualifications.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/my-qualifications.md
- Block docs: https://bettersupabase.com/docs/blocks/qualifications
```
