Particular.Tech
Blocksblock

Create organization

Name and URL slug form for a new organization.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/create-organization
npm dependencies
  • cn@^0.4.0
Installs these registry items

Usage

create-organization-demo.tsx
"use client"

import { CreateOrganizationForm } from "@/components/create-organization-form"
import { toast } from "@/components/ui/toast"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

export function Demo() {
  return (
    <div className="flex min-h-svh items-center justify-center bg-muted/30 p-6">
      <div className="w-full max-w-md rounded-2xl border bg-card p-8 shadow-sm">
        <h1 className="mb-1 font-heading text-xl font-semibold tracking-tight">Create an organization</h1>
        <p className="mb-6 text-sm text-muted-foreground">Organizations own API keys and members, separate from your personal account.</p>
        <CreateOrganizationForm
          slugPrefix="particular.tech/"
          onCancel={() => toast.add({ title: "Cancelled" })}
          onCreate={async ({ name, slug }) => {
            await wait(800)
            if (slug === "acme") throw new Error("That URL is taken. Choose another.")
            toast.add({ title: `Created ${name}`, type: "success" })
          }}
        />
      </div>
    </div>
  )
}

Source

blocks/create-organization/create-organization-form.tsx
"use client"

import * as React from "react"
import { cn } from "cn"

import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"

export type CreateOrganizationFormProps = {
  /** Resolve once created; the caller usually navigates to the new organization. Reject with an Error to show its message. */
  onCreate: (organization: { name: string; slug: string }) => Promise<void>
  /** Shown before the slug, e.g. `particular.tech/`. */
  slugPrefix?: string
  onCancel?: () => void
  className?: string
}

const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/

function slugify(name: string) {
  return name
    .normalize("NFKD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 39)
    .replace(/-+$/, "")
}

function CreateOrganizationForm({ onCreate, slugPrefix, onCancel, className }: CreateOrganizationFormProps) {
  const [name, setName] = React.useState("")
  const [slug, setSlug] = React.useState("")
  const [slugEdited, setSlugEdited] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)
  const [slugError, setSlugError] = React.useState<string | null>(null)

  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const trimmed = name.trim()
    if (!trimmed) return setError("Give the organization a name.")
    if (!SLUG_PATTERN.test(slug)) {
      return setSlugError("Use 1–39 lowercase letters, numbers, or hyphens, starting and ending with a letter or number.")
    }
    setPending(true)
    setError(null)
    setSlugError(null)
    try {
      await onCreate({ name: trimmed, slug })
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : "Something went wrong. Try again.")
    } finally {
      setPending(false)
    }
  }

  return (
    <form onSubmit={submit} className={cn("flex w-full max-w-md flex-col gap-5", className)}>
      <Field>
        <FieldLabel htmlFor="organization-name">Organization name</FieldLabel>
        <Input
          id="organization-name"
          name="name"
          value={name}
          autoComplete="organization"
          autoFocus
          placeholder="Acme Inc."
          onChange={(event) => {
            setName(event.target.value)
            if (!slugEdited) setSlug(slugify(event.target.value))
          }}
        />
      </Field>
      <Field data-invalid={slugError ? true : undefined}>
        <FieldLabel htmlFor="organization-slug">URL</FieldLabel>
        <InputGroup>
          {slugPrefix && (
            <InputGroupAddon>
              <InputGroupText className="font-mono text-xs">{slugPrefix}</InputGroupText>
            </InputGroupAddon>
          )}
          <InputGroupInput
            id="organization-slug"
            name="slug"
            value={slug}
            autoComplete="off"
            spellCheck={false}
            className="font-mono text-sm"
            aria-invalid={slugError ? true : undefined}
            onChange={(event) => {
              setSlugEdited(true)
              setSlug(event.target.value.toLowerCase())
            }}
          />
        </InputGroup>
        {slugError ? <FieldError>{slugError}</FieldError> : <FieldDescription>Lowercase letters, numbers, and hyphens. You can change it later.</FieldDescription>}
      </Field>
      {error && (
        <Alert variant="destructive">
          <AlertDescription>{error}</AlertDescription>
        </Alert>
      )}
      <div className="flex justify-end gap-2">
        {onCancel && (
          <Button type="button" variant="outline" disabled={pending} onClick={onCancel}>
            Cancel
          </Button>
        )}
        <Button type="submit" disabled={pending}>
          {pending && <Spinner data-icon="inline-start" />}
          Create organization
        </Button>
      </div>
    </form>
  )
}

export { CreateOrganizationForm }