@doany-ai/app-core
What every Doany site shares, as a package instead of scaffold source: the SDK client and query cache for the whole page, sign-in state and forms, base UI components, the theme, and images and uploads.
It does not create an SDK client or import @doany-ai/sdk: the site creates
one client and hands it to <DoanyProvider>, and every package on the page uses
that one — one sign-in, one query cache. It needs a client from
@doany-ai/sdk 0.5.22 or later.
Entries
| Import | What | Needs |
|---|---|---|
@doany-ai/app-core | DoanyProvider, useDoany, createQueryClient, readAppParams, cn, formatMoney | react, @tanstack/react-query |
@doany-ai/app-core/auth | AuthProvider, useAuth, useTestSite, AuthGate; LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, AuthLayout; the hooks the forms are built on: useLogin, useRegister, useForgotPassword, useResetPassword; defaultAuthLabels | — |
@doany-ai/app-core/router | ProtectedRoute, ScrollToTop, LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage | react-router-dom 6.4+ |
@doany-ai/app-core/account | useMyContact, useUpdateMyContact | AuthProvider |
@doany-ai/app-core/media | Image (media-worker resizing, private files), FileUpload | — |
@doany-ai/app-core/form | shared form fields, phone/country helpers, useVisitorCountry | — |
@doany-ai/app-core/ui | the base components (button, card, dialog, form, sidebar, toast…), also one by one: @doany-ai/app-core/ui/button | — |
@doany-ai/app-core/styles.css | the theme variables (also as theme.css) | — |
@doany-ai/app-core/tailwind-preset | the theme as a Tailwind 3 preset, with the page-wide base rules | tailwindcss 3 |
SheetContent completes same-tab fragment navigation after its exit animation:
when a link closes the sheet and the URL matches an existing section, it moves
focus and scrolls to that section. Ordinary dismissal still returns focus to
the trigger. A custom onCloseAutoFocus that prevents default keeps control.
The sheet does not close links automatically; the site's navigation owns that.
formatMoney(amount, currency, locale?) accepts the integer minor units from
the platform and formats them in the given locale. For example, 1,200,000
paisa is PKR 12,000. A locale's display defaults do not change that conversion.
Set up
The general scaffold (src/App.jsx, src/main.jsx, tailwind.config.js) already does this; keep it.
// main.jsx
import "@doany-ai/app-core/styles.css"; // the theme variables; the site's CSS comes after and wins
import "./index.css";
import { createClient } from "@doany-ai/sdk";
import { DoanyProvider, readAppParams } from "@doany-ai/app-core";
import { AuthGate, AuthProvider } from "@doany-ai/app-core/auth";
import { LoginPage, ProtectedRoute, RegisterPage } from "@doany-ai/app-core/router";
import { Toaster } from "@doany-ai/app-core/ui";
const params = readAppParams({ appId: import.meta.env.VITE_DOANY_APP_ID });
const doany = createClient({ appId: params.appId, token: params.token, serverUrl: "" });
root.render(
<DoanyProvider client={doany}>
<AuthProvider token={params.token}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage authenticatedHome="/" />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="*" element={<AuthGate><SiteRoutes /></AuthGate>} />
</Routes>
<Toaster />
</BrowserRouter>
</AuthProvider>
</DoanyProvider>
);
// tailwind.config.cjs — one build for the site's classes and the packages'
module.exports = {
presets: [require("@doany-ai/app-core/tailwind-preset")],
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}", "./node_modules/@doany-ai/*/dist/**/*.js"],
};
The packages ship no utilities: the site's Tailwind build scans them. Two
builds on one page break the components — each is ordered on its own, so a
utility the site also uses (px-3) lands after a component's more specific
one (pl-10, the login form's room for its icon) and overrides it. One build
orders them together.
Customizing
Three levels, each without touching the package's code. Try them in this order.
1. CSS only — the theme. Set the variables on :root, after the package's
CSS. Every component of every Doany package follows, dialogs included. On
:root, not on a wrapper: dialogs and menus render at the end of <body>,
outside any wrapper.
:root { --primary: 221 83% 53%; --radius: 1rem; --font-body: "Inter", sans-serif; }
The variables: --background, --foreground, --card, --popover,
--primary, --secondary, --muted, --accent, --destructive,
--success (paid, confirmed), --border, --input, --ring (focus), each
with its -foreground where text sits on it; --radius; --font-body,
--font-heading. The defaults are in src/styles/theme.css, with a .dark
set.
The site's own CSS reaches the components too. A rule on p, label,
input, select, textarea or button, alone or under a wrapper
(.contact p, .form-card input), also restyles what the components render
there: field labels, the fields, help text and error messages (<p>), and
checkboxes and radio choices (<button>). An error message recoloured that way
no longer reads as an error; a checkbox given a button's size stretches. Give
the site's own elements a class and style the class; change a component only
through the variables above or its className.
2. Props only — words, parts, links, behavior. The sign-in forms take
title, subtitle, icon, providers ([] hides Google), the other pages'
hrefs, authenticatedHome, renderLink, and onSignedIn / onAccountCreated
/ onReset in place of the default page load. Every word they show is in
labels — pass a part of AuthLabels and the rest stay the default; one
object can carry a whole translation to every form:
<LoginForm title="Mori" providers={[]} labels={{ email: "Work email", logIn: "Sign in", loginFailed: "Check your email and password" }} />
A failure is said in the platform's words (too many attempts, a taken email)
unless the site sets its label (loginFailed, registerFailed, …): then
always in the site's — a translated site shows no English error.
AuthGate takes loading and notRegistered; ProtectedRoute takes
fallback, unauthenticatedElement, notRegistered and loginPath;
UserNotRegisteredError and FileUpload take labels too.
3. Hooks only — your own markup. The forms are markup over hooks that keep their behavior: where a sign-in comes back to (same origin only, never a sign-in page), how a new account gets its session, when the site learns who signed in.
const { login, loginWithProvider, pending, error } = useLogin({ onSignedIn: (to) => navigate(to) });
// error: { reason: "rejected" | "account_unavailable" | …, message } — say it in your words
useRegister (with its verify step), useForgotPassword,
useResetPassword, and useAuth() / useDoany() for the state and the
client.
Reference
Form controls: @doany-ai/app-core/form
fieldClass, textareaClass and selectClass share left-aligned content;
inputs and selects are 44px tall. NativeSelect takes native single-select props and a ref;
its inset arrow leaves 12px at the right and reserves room for long labels.
It keeps native keyboard selection, required validation and form values.
selectClass remains available for an unwrapped native select.
IconInput reserves room for its leading icon; CountrySelect uses the same
inset arrow as NativeSelect. The base Input with type="date" keeps the
browser's date entry and picker, with the date on the left and picker on the right.
Date inputs have a soft shadow and a subdued picker icon. A controlled empty
date uses the placeholder color until focused; a selected date uses normal text.
Phone country: @doany-ai/app-core/form
useVisitorCountry(enabled = true) returns the visitor's country for an empty
phone field. It starts with an explicit region in the browser's language
preferences, or US, then uses the supported loc country from the same
origin's /cdn-cgi/trace response. The request runs once per page, bypasses
caches and times out after three seconds; an unavailable endpoint keeps the
browser fallback. It stores no location or trace in browser storage. The page's
language does not decide the visitor's country.
Form and AttendeeForm apply this default only until the visitor chooses a
country or starts typing. Saved phone values keep their country. A custom form
using the hook must keep the same boundary and use the selected country when
converting a local number with phoneFrom(value, dialOf(country)).
Sign-in: @doany-ai/app-core/auth, @doany-ai/app-core/router
The four sign-in pages are route components over the forms: LoginPage,
RegisterPage, ForgotPasswordPage and ResetPasswordPage (/router) take
the same props as LoginForm, RegisterForm, ForgotPasswordForm and
ResetPasswordForm (/auth), and link between each other through the router.
Each form already includes AuthLayout: render it directly, passing title,
subtitle and className as needed. Wrapping a form or route page in another
AuthLayout nests two full-height cards and compresses the fields. The shared
layout uses one responsive card, capped at 30rem, with page gutters on phones.
Keep them at /login, /register, /forgot-password and /reset-password:
the reset email links to /reset-password?token=.
Every form takes icon, title, subtitle (wins over the labels), labels
and className, and:
| Form | Props |
|---|---|
LoginForm | authenticatedHome (where to go when there is no from_url; default /), fromUrl (default: the page's ?from_url=), registerHref, forgotPasswordHref (defaults /register and /forgot-password, carrying a local from_url), providers (default ["google"]; [] hides Google), renderLink, onSignedIn(destination) (default: load it) |
RegisterForm | fromUrl (default: the page's ?from_url=; the new account lands there), authenticatedHome, loginHref (default /login, carrying from_url), providers, renderLink, onAccountCreated() (already verified: sign in next), onSignedIn(destination) |
ForgotPasswordForm | loginHref (default /login, carrying the page's from_url), renderLink |
ResetPasswordForm | resetToken (default: the page's ?token=), loginHref, forgotPasswordHref, renderLink, onReset() (default: load loginHref) |
The hooks the forms are built on:
| Hook | Returns |
|---|---|
useLogin({ fromUrl?, authenticatedHome?, onSignedIn? }) | login(email, password) → true when signed in, loginWithProvider("google"), destination, pending, error, clearError() |
useRegister({ fromUrl?, authenticatedHome?, loginHref?, onAccountCreated?, onSignedIn? }) | step (details → verify), email, register({ email, password, confirmPassword? }) → "verify" (a code was emailed) / "created" (no code needed) / null, verify(code), resend(), loginWithProvider, destination, pending, error, clearError() |
useForgotPassword() | request(email), sent, pending |
useResetPassword({ resetToken?, loginHref?, onReset? }) | token, reset(newPassword, confirmPassword?), pending, error, clearError() |
error is { reason, message }: reason is rejected, account_unavailable,
passwords_differ or resend_failed; message is the platform's words. Say it
in the site's words.
Signed-in state
useAuth() (needs AuthProvider) returns user ({ id, email, full_name, role }
or null), isAuthenticated, isLoadingAuth, authChecked,
appPublicSettings, logout(returnTo?) (goes through the platform and comes
back; default the current page), navigateToLogin(), and refresh(token?)
(read the sign-in again after signing in without a page load).
The website editor
In the website editor what a visitor does is saved as a test: it shows in the
business's records with a Test tag, counts for nothing and reaches no client.
useTestSite() (under AuthProvider, from the platform's public settings) is
the one way a component learns it. The site itself shows only what its clients
see, in the editor too: the editor says above the page what was saved.
An account made in the editor is a test account: it signs in only there, and a
real account does not sign in there. RegisterForm and LoginForm are the same
there; a real account signing in in the editor is refused in the platform's
words ("Sign in with a test account made here.").
| Component | Props |
|---|---|
AuthGate | wraps the site's routes: shows them once the site's settings and the sign-in are known, and sends a guest to /login on a login-only site. loading, notRegistered |
ProtectedRoute (/router) | a layout route for pages that need a signed-in account: <Route element={<ProtectedRoute />}>…</Route>. fallback (while checking settings or sign-in), labels (loadError, retry for a recoverable read failure), unauthenticatedElement (instead of going to the login page; null counts), loginPath (default /login), notRegistered |
ScrollToTop (/router) | mount once inside the router. It also tells the platform each page a visitor is on (usePageViews: the page a visit lands on, then once per navigation to another path, each with the title the page set; the SDK, 0.5.22 or later, waits for the first so the landing page is recorded once, and decides whether anything is collected). New page navigation scrolls to the top; an initial or newly navigated #id waits for its section to render (including behind AuthGate), then scrolls there. Back/forward keeps the browser's scroll restoration. Waiting ends when the target is found, navigation changes, or the component unmounts. |
UserNotRegisteredError | the screen for a signed-in visitor who is not an account of this site. title, message, labels |
AuthLayout | the card the sign-in forms sit in. icon, title (required), subtitle, footer |
A failed settings or account read (for example, a network error or HTTP 500)
keeps the protected page hidden and shows an error with a Try again button.
The button calls the existing checkAppState() to read settings and sign-in
again; the recheck itself submits no business action. A transient failure
does not discard the session. Only active reads use fallback; authentication
refusals and not-registered screens keep their existing behavior. The error uses
fixed text or labels,
without exposing the backend error message.
Account: @doany-ai/app-core/account
| Hook | Returns |
|---|---|
useMyContact() | a query; data is the signed-in account's contact: display_name, given_name, family_name, email, phone, address (line, city, region, postal_code, country), timezone, preferred_language, version. Nothing for a guest |
useUpdateMyContact() | a mutation: mutateAsync({ version, display_name?, given_name?, family_name?, phone?, address?, timezone?, preferred_language? }). Send the version last read; a 409 means it changed meanwhile: read again and retry. The email follows the sign-in and cannot be changed here |
Media: @doany-ai/app-core/media
imageDisplayUrl(src, width, height?) returns a display-sized WebP URL for an
existing platform image without changing its stored original. Width and height
are output pixels; pass both for a centered crop or only width to preserve the
image. Existing private signatures are retained, GIFs retain animation, and SVG
and external URLs are unchanged. It does not sign or modify vendor thumbnails.
Use Image for editable site imagery: it serves the image resized to its box,
and signs a private file's media:// URI before showing it. Size the box with
className. The loading placeholder stays inside the image box and follows its
border radius. Add shadows to the box with className or style.
For fixed course thumbnails and controls that take a poster URL,
use imageDisplayUrl to request the appropriate output size directly.
| Component | Props |
|---|---|
Image | every <img> prop, and fittingType (fill, default, crops; fit shows all), originWidth / originHeight (hold the aspect ratio before loading), focalPointX / focalPointY (0–1, kept in view when cropping), quality (1–100, default 90) |
FileUpload | every file <input> prop except value, onChange, type, and: value (the stored file's URL), onChange(url) (called once the upload finishes, so a form's submit never waits for it), privateFile (store privately; the value is a media:// URI), onUploadError, children (shown once there is a value: a preview, a Remove button), labels (uploading, uploadFailed) |
Base UI: @doany-ai/app-core/ui
The shadcn components (new-york), with their usual props: accordion,
alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb, button, calendar,
card, carousel, chart, checkbox, collapsible, command, context-menu, dialog,
drawer, dropdown-menu, form, hover-card, input-otp, input, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
switch, table, tabs, textarea, toast / toaster (useToast), toggle-group,
toggle, tooltip. Import from @doany-ai/app-core/ui, or one by one from
@doany-ai/app-core/ui/<name>. cn is in @doany-ai/app-core. Icons come
from lucide-react. There is no @/components/ui in the site.
Two composed ones beside them: DataTable (columns: { key, header, cell?, align? }[], rows, pageSize (10), empty (what to show without rows),
rowKey, labels) draws rows in a table and turns pages on the page itself;
EmptyState (icon, title, description, action) is what a list shows
before it has anything. The sidebar's colors are the --sidebar-* variables
in styles.css (background, foreground, primary, primary-foreground, accent,
accent-foreground, border, ring), set for light and .dark.
Long CTA labels
Button keeps its usual single-line, fixed-height default (including icon
buttons). For a long call to action use wrap: the label can wrap, the button
cannot exceed its container, and its height grows from the selected size's
minimum height. This also works with asChild links.
<Button wrap size="lg" className="min-h-12 rounded-full">
See a cleaning-business example
</Button>
Use min-h-*, not a fixed h-*, when customizing a wrapping button. Keep its
grid/flex parents shrinkable (min-w-0); do not give the label whitespace-nowrap.
Card grids and full-width rows
cardGrid(count, size = "narrow", layout = "cards") (from the root entry)
returns the shared grid classes. cards uses the available container width
to choose columns, caps each tile at 24rem (wide: 28rem), and centers one or
two tiles. list is one full-width column with no tile or small-count width
cap; use it for custom service rows and multi-column content. CardGridLayout
is the exported "cards" | "list" type.
Neither mode can infer the layout inside arbitrary custom markup. Inside a
row, choose columns from the row's available space, not the viewport. For
example, grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))] with
min-w-0 children stacks when the row is narrow, even on a desktop screen.
For unequal columns, use a CSS container query on a wrapper around the row.
Do not override a narrow card to two columns with a viewport lg: rule.
Embed: the same content, four ways
Embed (in @doany-ai/app-core/ui) puts what it wraps — a form, a booking flow — in the page (inline, the default), in a dialog (popup), in a panel fixed to a corner that minimizes to a tab (sidebar), or in a panel that slides in from a side and can be closed (slide-in). trigger says when it opens: { type: "always" } (default), { type: "after", seconds }, { type: "scroll", percent }. deactivate says when it stops opening: { afterShows: n }, { onceSubmitted: true } — counted per storageKey in the browser's localStorage. Inside, useEmbed() gives close() and markSubmitted().
<Embed layout="popup" label="Get in touch" trigger={{ type: "after", seconds: 5 }} deactivate={{ afterShows: 2 }}>
<Form formId={siteConfig.contactFormId} showTitle={false} />
</Embed>
Auth labels
Every word the sign-in forms and screens show is in AuthLabels;
defaultAuthLabels holds the defaults. A form takes a part and keeps the
defaults for the rest.
| Where | Keys |
|---|---|
| Shared | email, emailPlaceholder, password, confirmPassword, or, continueWithGoogle |
| Login | loginTitle, loginSubtitle, logIn, loggingIn, forgotPassword, noAccount, createOne, loginFailed, accountUnavailable |
| Register | registerTitle, registerSubtitle, createAccount, creatingAccount, haveAccount, logInLink, passwordsDiffer, registerFailed, accountCreated, accountCreatedBody |
| Email code | verifyTitle, codeSentTo, verify, verifying, noCode, resend, codeResent, codeResentBody, invalidCode, resendFailed, newAccountUnavailable |
| Forgot password | forgotTitle, forgotSubtitle, emailAddress, sendResetLink, sending, resetLinkSent, backToLogin |
| Reset password | resetTitle, resetSubtitle, newPassword, resetPassword, resetting, resetFailed, invalidResetLink, invalidResetLinkSubtitle, invalidResetLinkBody, requestNewLink |
| Not an account of this site | notRegisteredTitle, notRegisteredMessage, notRegisteredHelp, notRegisteredSteps |
Never edit this package under node_modules or copy its source into the site.
Develop
Account profile
@doany-ai/app-core/account exports ProfileForm and useProfileForm. Both
use the existing contacts.me / versioned contacts.updateMe contract. The
form edits the contact display name; the sign-in email is read-only. Loading,
missing profiles, save failures and version conflicts are visible. A conflict
requires reloading before another save. Drafts and feedback follow the current
account, so an account switch never displays the preceding account's draft.
Use labels, footer, className and classNames for supported customization.
ProfileFormExample demonstrates mode="default" | "css" | "props" | "hook";
each owns an isolated in-memory account and cannot write to a real account.
The CSS example changes theme tokens, the props example changes labels/footer,
and the hook example draws custom markup on the same useProfileForm owner.
Where it came from
Extracted from the v5 scaffold's src (since removed; doany-general-scaffold is built on these packages instead), keeping its behavior:
| Here | Scaffold |
|---|---|
DoanyProvider, createQueryClient | App.jsx's QueryClientProvider, lib/query-client.js; the client came from api/doanyClient.js |
readAppParams | lib/app-params.js (defaults passed in instead of read from import.meta.env) |
auth/ | lib/AuthContext.jsx, lib/login-code.js, lib/login-navigation.js, pages/*, components/AuthLayout.jsx, GoogleIcon.jsx, UserNotRegisteredError.jsx, App.jsx's auth gate |
router/ | components/ProtectedRoute.jsx, components/ScrollToTop.jsx |
media/ | components/ui/image.jsx, file-upload.jsx, lib/image-layout.js, hooks/use-size.jsx |
ui/ | components/ui/*, hooks/use-mobile.jsx |
AuthProvider reads the site's settings with doany.app.getPublicSettings()
and trades a sign-in code with doany.auth.exchangeLoginCode() instead of
building its own axios clients from SDK internals.
The ui/ components are shadcn's new-york TypeScript sources with the
scaffold's differences applied, so each file, with its types stripped, matches
the scaffold's .jsx — class strings, TOAST_LIMIT = 20, SidebarTrigger's
asChild. Kept from upstream where it is only safer: FormMessage and
ChartTooltipContent guard an undefined value, and the mobile sidebar carries a
screen-reader title.
Build and test
npm ci
npm test # vitest + jsdom
npm run typecheck
npm run build # dist/: ESM + .d.ts per module, styles.css (the theme variables)