mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 352ca65838 | |||
| 44d653ffbd | |||
| 76da310766 | |||
| c326e9f794 | |||
| b299501ab2 | |||
| 17209a2cf1 | |||
| bd8fdfadda | |||
| 7846dacd42 | |||
| 2c239fbd27 | |||
| bffed5525d | |||
| 591b2f9170 | |||
| b557139752 | |||
| 2d7df1bec0 | |||
| feffce6cbf | |||
| 35f07c508d | |||
| 143ffc39f1 | |||
| b33561d7e1 | |||
| 947623a691 | |||
| dd64d689c8 | |||
| 1c5e71eb39 | |||
| fb9fa299c9 | |||
| ab17978b5c | |||
| 36461a5498 | |||
| 01dbc07e92 | |||
| 233ce9f854 | |||
| 99aa534e88 | |||
| ef76afc3ca |
@@ -0,0 +1,199 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an approval
|
||||
from the project maintainers before using the Apache License.
|
||||
|
||||
Copyright 2025 NextUI Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: heroui-migration
|
||||
description: "HeroUI v2 to v3 migration guide for agents. Use when migrating HeroUI v2 apps to v3, upgrading components, or accessing migration documentation. Keywords: HeroUI migration, v2 to v3, migration guide, upgrade HeroUI."
|
||||
metadata:
|
||||
author: heroui
|
||||
version: "2.0.0"
|
||||
status: preview
|
||||
---
|
||||
|
||||
# HeroUI v2 to v3 Migration Guide
|
||||
|
||||
This skill helps agents migrate HeroUI v2 applications to v3. HeroUI v3 introduces breaking changes: compound components, no Provider, Tailwind v4, and removed hooks.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://heroui.com/install | bash -s heroui-migration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: Always Fetch Migration Docs Before Applying
|
||||
|
||||
**Do NOT assume v2 patterns work in v3.** Always fetch migration guides before implementing changes.
|
||||
|
||||
### Key v2 → v3 Changes
|
||||
|
||||
| Feature | v2 (Migrate From) | v3 (Migrate To) |
|
||||
| ------------- | -------------------------- | -------------------------------------- |
|
||||
| Provider | `<HeroUIProvider>` required | **No Provider needed** |
|
||||
| Component API | Flat props: `<Card title="x">` | Compound: `<Card><Card.Header>` |
|
||||
| Event handlers | `onClick` | `onPress` |
|
||||
| Styling | `classNames` prop | `className` prop |
|
||||
| Hooks | `useSwitch`, `useDisclosure`, etc. | Compound components, `useOverlayState` |
|
||||
| Packages | `@heroui/system`, `@heroui/theme` | `@heroui/react`, `@heroui/styles` |
|
||||
|
||||
---
|
||||
|
||||
## Accessing Migration Documentation
|
||||
|
||||
**For migration details, examples, and step-by-step guides, always fetch documentation:**
|
||||
|
||||
### Using Scripts
|
||||
|
||||
```bash
|
||||
# List all available component migration guides
|
||||
node scripts/list_migration_guides.mjs
|
||||
|
||||
# Get main migration workflow (full or incremental)
|
||||
node scripts/get_migration_guide.mjs full
|
||||
node scripts/get_migration_guide.mjs incremental
|
||||
|
||||
# Get component-specific migration guides
|
||||
node scripts/get_component_migration_guides.mjs button
|
||||
node scripts/get_component_migration_guides.mjs button card modal
|
||||
|
||||
# Get styling migration guide
|
||||
node scripts/get_styling_migration_guide.mjs
|
||||
|
||||
# Get hooks migration guide
|
||||
node scripts/get_hooks_migration_guide.mjs
|
||||
```
|
||||
|
||||
### Direct URLs
|
||||
|
||||
Migration docs (preview): `https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration/{filename}`
|
||||
|
||||
Examples:
|
||||
|
||||
- Full migration: `.../agent-guide-full.mdx`
|
||||
- Incremental: `.../agent-guide-incremental.mdx`
|
||||
- Button: `.../button.mdx`
|
||||
- Styling: `.../styling.mdx`
|
||||
- Hooks: `.../hooks.mdx`
|
||||
|
||||
Override base URL with `HEROUI_MIGRATION_DOCS_BASE` when docs are merged to production.
|
||||
|
||||
### MCP Alternative
|
||||
|
||||
When using Cursor or other MCP clients, configure the Migration MCP server for tool-based access:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"heroui-migration": {
|
||||
"url": "https://migration-mcp.heroui.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### Full Migration
|
||||
|
||||
- Best for: Projects that can dedicate focused time; teams comfortable with temporarily broken code
|
||||
- Migrate all component code first (project broken during migration)
|
||||
- Switch dependencies to v3
|
||||
- Complete styling migration
|
||||
|
||||
### Incremental Migration
|
||||
|
||||
- Best for: Projects that must stay functional; large codebases migrating gradually
|
||||
- Set up coexistence (pnpm aliases or component packages)
|
||||
- Migrate components one-by-one
|
||||
- Both v2 and v3 coexist during migration
|
||||
|
||||
**Always fetch the agent guide before starting:** `node scripts/get_migration_guide.mjs full` or `incremental`
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Fetch first**: Use scripts to get migration guides before applying changes
|
||||
2. **Compound components**: v3 uses `Card.Header`, `Card.Title`, `Button` with children—not flat props
|
||||
3. **No Provider**: Remove `HeroUIProvider` when migrating
|
||||
4. **onPress not onClick**: All interactive components use `onPress`
|
||||
5. **Workflow**: Analyze → Migrate components → Switch deps → Styling migration
|
||||
|
||||
---
|
||||
|
||||
## Migration Workflow Summary
|
||||
|
||||
1. Create migration branch
|
||||
2. Analyze project (HeroUI imports, component usage)
|
||||
3. Fetch main guide: `node scripts/get_migration_guide.mjs full`
|
||||
4. Migrate components in batches (fetch component guides per batch)
|
||||
5. Switch dependencies to v3
|
||||
6. Fetch styling guide: `node scripts/get_styling_migration_guide.mjs`
|
||||
7. Apply styling updates
|
||||
|
||||
---
|
||||
|
||||
## Preview Mode
|
||||
|
||||
This skill targets the staging deployment of the `docs/migration` branch. Once docs are merged to main and live on heroui.com, set `HEROUI_MIGRATION_DOCS_BASE=https://heroui.com/docs/react/migration` or update the default in scripts.
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get migration guides for HeroUI components (v2 to v3).
|
||||
*
|
||||
* Usage:
|
||||
* node get_component_migration_guides.mjs button
|
||||
* node get_component_migration_guides.mjs button card modal
|
||||
*
|
||||
* Output:
|
||||
* MDX migration guide content for each component
|
||||
*/
|
||||
|
||||
const DOCS_BASE =
|
||||
process.env.HEROUI_MIGRATION_DOCS_BASE ||
|
||||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
|
||||
const APP_PARAM = "app=migration-skills";
|
||||
|
||||
/**
|
||||
* Convert PascalCase or mixed case to kebab-case.
|
||||
*/
|
||||
function toKebabCase(name) {
|
||||
return name
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchDoc(filename) {
|
||||
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_component_migration_guides.mjs <Component1> [Component2] ...");
|
||||
console.error("Example: node get_component_migration_guides.mjs button card modal");
|
||||
console.error("\nUse list_migration_guides.mjs to see all available components.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const components = args.map((c) => toKebabCase(c));
|
||||
|
||||
console.error(`# Fetching migration guides for: ${components.join(", ")}...`);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const component of components) {
|
||||
const filename = `${component}.mdx`;
|
||||
|
||||
try {
|
||||
const content = await fetchDoc(filename);
|
||||
const title = component.charAt(0).toUpperCase() + component.slice(1).replace(/-/g, " ");
|
||||
|
||||
results.push({
|
||||
component,
|
||||
content: `# ${title} Migration Guide\n\n**Component:** ${component}\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
component,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.error);
|
||||
|
||||
if (failed.length > 0) {
|
||||
failed.forEach((r) => console.error(`# Error for ${r.component}: ${r.error}`));
|
||||
}
|
||||
|
||||
if (results.length === 1) {
|
||||
const r = results[0];
|
||||
|
||||
if (r.content) {
|
||||
console.log(r.content);
|
||||
} else {
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const output = results
|
||||
.map((r) => (r.content ? r.content : `# ${r.component} Migration Guide\n\nError: ${r.error}`))
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
console.log(output);
|
||||
if (failed.length === results.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get the hooks migration guide for HeroUI v2 to v3.
|
||||
*
|
||||
* Usage:
|
||||
* node get_hooks_migration_guide.mjs
|
||||
*
|
||||
* Output:
|
||||
* MDX hooks migration guide content
|
||||
*/
|
||||
|
||||
const DOCS_BASE =
|
||||
process.env.HEROUI_MIGRATION_DOCS_BASE ||
|
||||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
|
||||
const APP_PARAM = "app=migration-skills";
|
||||
|
||||
async function fetchDoc(filename) {
|
||||
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filename = "hooks.mdx";
|
||||
|
||||
console.error("# Fetching hooks migration guide...");
|
||||
|
||||
try {
|
||||
const content = await fetchDoc(filename);
|
||||
|
||||
console.log(
|
||||
`# HeroUI v2 to v3 Hooks Migration Guide\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`# Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get the main migration workflow guide (full or incremental) for HeroUI v2 to v3.
|
||||
*
|
||||
* Usage:
|
||||
* node get_migration_guide.mjs [full|incremental]
|
||||
*
|
||||
* Default: full
|
||||
*
|
||||
* Output:
|
||||
* MDX migration guide content
|
||||
*/
|
||||
|
||||
const DOCS_BASE =
|
||||
process.env.HEROUI_MIGRATION_DOCS_BASE ||
|
||||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
|
||||
const APP_PARAM = "app=migration-skills";
|
||||
|
||||
async function fetchDoc(filename) {
|
||||
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const arg = (process.argv[2] || "full").toLowerCase();
|
||||
const migrationType = arg === "incremental" ? "incremental" : "full";
|
||||
const filename =
|
||||
migrationType === "incremental" ? "agent-guide-incremental.mdx" : "agent-guide-full.mdx";
|
||||
|
||||
console.error(`# Fetching ${migrationType} migration guide...`);
|
||||
|
||||
try {
|
||||
const content = await fetchDoc(filename);
|
||||
const title =
|
||||
migrationType === "incremental"
|
||||
? "HeroUI v2 to v3 Agent Migration Guide - Incremental Migration"
|
||||
: "HeroUI v2 to v3 Agent Migration Guide - Full Migration";
|
||||
|
||||
console.log(`# ${title}\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`);
|
||||
} catch (error) {
|
||||
console.error(`# Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get the styling migration guide for HeroUI v2 to v3.
|
||||
*
|
||||
* Usage:
|
||||
* node get_styling_migration_guide.mjs
|
||||
*
|
||||
* Output:
|
||||
* MDX styling migration guide content
|
||||
*/
|
||||
|
||||
const DOCS_BASE =
|
||||
process.env.HEROUI_MIGRATION_DOCS_BASE ||
|
||||
"https://heroui-git-docs-migration-heroui.vercel.app/docs/react/migration";
|
||||
const APP_PARAM = "app=migration-skills";
|
||||
|
||||
async function fetchDoc(filename) {
|
||||
const url = `${DOCS_BASE}/${filename}?${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Migration-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch ${filename}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filename = "styling.mdx";
|
||||
|
||||
console.error("# Fetching styling migration guide...");
|
||||
|
||||
try {
|
||||
const content = await fetchDoc(filename);
|
||||
|
||||
console.log(
|
||||
`# HeroUI v2 to v3 Styling Migration Guide\n\n**Source:** ${DOCS_BASE}/${filename}\n\n---\n\n${content}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`# Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* List all available HeroUI v2 to v3 component migration guides.
|
||||
*
|
||||
* Usage:
|
||||
* node list_migration_guides.mjs
|
||||
*
|
||||
* Output:
|
||||
* List of component names that have migration guides
|
||||
*
|
||||
* Note: Keep in sync with migration-mcp list-migration-guides.ts when adding components.
|
||||
*/
|
||||
|
||||
// Component migration guides available - must match migration-mcp list-migration-guides.ts
|
||||
const AVAILABLE_COMPONENTS = [
|
||||
"accordion",
|
||||
"alert",
|
||||
"autocomplete",
|
||||
"avatar",
|
||||
"breadcrumbs",
|
||||
"button",
|
||||
"button-group",
|
||||
"card",
|
||||
"checkbox",
|
||||
"checkbox-group",
|
||||
"chip",
|
||||
"code",
|
||||
"divider",
|
||||
"dropdown",
|
||||
"form",
|
||||
"image",
|
||||
"input",
|
||||
"input-otp",
|
||||
"kbd",
|
||||
"link",
|
||||
"listbox",
|
||||
"modal",
|
||||
"navbar",
|
||||
"numberinput",
|
||||
"popover",
|
||||
"radio",
|
||||
"radio-group",
|
||||
"scroll-shadow",
|
||||
"select",
|
||||
"skeleton",
|
||||
"slider",
|
||||
"snippet",
|
||||
"spacer",
|
||||
"spinner",
|
||||
"switch",
|
||||
"tabs",
|
||||
"toast",
|
||||
"tooltip",
|
||||
"user",
|
||||
];
|
||||
|
||||
function main() {
|
||||
const componentsList = AVAILABLE_COMPONENTS.map((name) => ` - ${name}`).join("\n");
|
||||
|
||||
console.log(
|
||||
`# Available Component Migration Guides\n\nFound ${AVAILABLE_COMPONENTS.length} component migration guides:\n\n${componentsList}\n\nUse get_component_migration_guides.mjs with component names to fetch specific guides.\nExample: node get_component_migration_guides.mjs button card modal`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,199 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an approval
|
||||
from the project maintainers before using the Apache License.
|
||||
|
||||
Copyright 2025 NextUI Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: heroui-native
|
||||
description: "HeroUI Native component library for React Native (Tailwind v4 via Uniwind). Use when building mobile UIs with HeroUI Native — creating Buttons, Cards, TextFields, Dialogs; installing heroui-native; configuring dark/light themes; or fetching component docs. Keywords: HeroUI Native, heroui-native, React Native UI, Uniwind, mobile components."
|
||||
metadata:
|
||||
author: heroui
|
||||
version: "2.0.1"
|
||||
---
|
||||
|
||||
# HeroUI Native Development Guide
|
||||
|
||||
HeroUI Native is a component library built on **Uniwind (Tailwind CSS for React Native)** and **React Native**, providing accessible, customizable UI components for mobile applications.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://heroui.com/install | bash -s heroui-native
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: Native Only - Do Not Use Web Patterns
|
||||
|
||||
**This guide is for HeroUI Native ONLY.** Do NOT apply HeroUI React (web) patterns — the package, styling engine, and color format all differ:
|
||||
|
||||
| Feature | React (Web) | Native (Mobile) |
|
||||
| ------------ | -------------------- | ----------------------------------- |
|
||||
| **Styling** | Tailwind CSS v4 | Uniwind (Tailwind for React Native) |
|
||||
| **Colors** | oklch format | HSL format |
|
||||
| **Package** | `@heroui/react` | `heroui-native` |
|
||||
| **Platform** | Web browsers | iOS & Android |
|
||||
|
||||
```tsx
|
||||
// CORRECT — Native pattern
|
||||
import { Button } from "heroui-native";
|
||||
|
||||
<Button variant="primary" onPress={() => console.log("Pressed!")}>
|
||||
Click me
|
||||
</Button>;
|
||||
```
|
||||
|
||||
**Always fetch Native docs before implementing.**
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
|
||||
- Composition over configuration (compound components)
|
||||
- Theme variables with HSL color format
|
||||
- React Native StyleSheet patterns with Uniwind utilities
|
||||
|
||||
---
|
||||
|
||||
## Accessing Documentation & Component Information
|
||||
|
||||
**For component details, examples, props, and implementation patterns, always fetch documentation:**
|
||||
|
||||
### Using Scripts
|
||||
|
||||
```bash
|
||||
# List all available components
|
||||
node scripts/list_components.mjs
|
||||
|
||||
# Get component documentation (MDX)
|
||||
node scripts/get_component_docs.mjs Button
|
||||
node scripts/get_component_docs.mjs Button Card TextField
|
||||
|
||||
# Get theme variables
|
||||
node scripts/get_theme.mjs
|
||||
|
||||
# Get non-component docs (guides, releases)
|
||||
node scripts/get_docs.mjs /docs/native/getting-started/theming
|
||||
```
|
||||
|
||||
### Direct MDX URLs
|
||||
|
||||
Component docs: `https://heroui.com/docs/native/components/{component-name}.mdx`
|
||||
|
||||
Examples:
|
||||
|
||||
- Button: `https://heroui.com/docs/native/components/button.mdx`
|
||||
- Dialog: `https://heroui.com/docs/native/components/dialog.mdx`
|
||||
- TextField: `https://heroui.com/docs/native/components/text-field.mdx`
|
||||
|
||||
Getting started guides: `https://heroui.com/docs/native/getting-started/{topic}.mdx`
|
||||
|
||||
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
|
||||
|
||||
---
|
||||
|
||||
## Installation Essentials
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
npm i heroui-native react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
|
||||
```
|
||||
|
||||
### Framework Setup (Expo - Recommended)
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npx create-expo-app MyApp
|
||||
cd MyApp
|
||||
npm i heroui-native uniwind tailwindcss
|
||||
npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
|
||||
```
|
||||
|
||||
2. **Create `global.css`:**
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "uniwind";
|
||||
@import "heroui-native/styles";
|
||||
|
||||
@source "./node_modules/heroui-native/lib";
|
||||
```
|
||||
|
||||
3. **Wrap app with providers:**
|
||||
|
||||
```tsx
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { HeroUINativeProvider } from "heroui-native";
|
||||
import "./global.css";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<HeroUINativeProvider>
|
||||
<App />
|
||||
</HeroUINativeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Setup Requirements
|
||||
|
||||
1. **Uniwind is Required** - HeroUI Native uses Uniwind (Tailwind CSS for React Native)
|
||||
2. **HeroUINativeProvider Required** - Wrap your app with `HeroUINativeProvider`
|
||||
3. **GestureHandlerRootView Required** - Wrap with `GestureHandlerRootView` from react-native-gesture-handler
|
||||
4. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Body`)
|
||||
5. **Use onPress, not onClick** - React Native uses `onPress` event handlers
|
||||
6. **Platform-Specific Code** - Use `Platform.OS` for iOS/Android differences
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
HeroUI Native uses **compound component patterns**. Each component has subcomponents accessed via dot notation.
|
||||
|
||||
**Example - Card:**
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<Card.Header>{/* Icons, badges */}</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Title>Title</Card.Title>
|
||||
<Card.Description>Description</Card.Description>
|
||||
</Card.Body>
|
||||
<Card.Footer>{/* Actions */}</Card.Footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Always use compound structure - don't flatten to props
|
||||
- Subcomponents are accessed via dot notation (e.g., `Card.Header`)
|
||||
- Native Card uses `Card.Body` (not `Card.Content`); Title and Description go inside Body
|
||||
- **Fetch component docs for complete anatomy and examples**
|
||||
|
||||
---
|
||||
|
||||
## Semantic Variants
|
||||
|
||||
HeroUI uses semantic naming to communicate functional intent:
|
||||
|
||||
| Variant | Purpose | Usage |
|
||||
| ------------- | --------------------------------- | -------------- |
|
||||
| `primary` | Main action to move forward | 1 per context |
|
||||
| `secondary` | Alternative actions | Multiple |
|
||||
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
|
||||
| `danger` | Destructive actions | When needed |
|
||||
| `danger-soft` | Soft destructive actions | Less prominent |
|
||||
| `ghost` | Low-emphasis actions | Minimal weight |
|
||||
| `outline` | Secondary actions | Bordered style |
|
||||
|
||||
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
|
||||
|
||||
---
|
||||
|
||||
## Theming
|
||||
|
||||
HeroUI Native uses CSS variables via Tailwind/Uniwind for theming. Theme colors are defined in `global.css`:
|
||||
|
||||
```css
|
||||
@theme {
|
||||
--color-accent: hsl(260, 100%, 70%);
|
||||
--color-accent-foreground: hsl(0, 0%, 100%);
|
||||
}
|
||||
```
|
||||
|
||||
**Get current theme variables:**
|
||||
|
||||
```bash
|
||||
node scripts/get_theme.mjs
|
||||
```
|
||||
|
||||
**Access theme colors programmatically:**
|
||||
|
||||
```tsx
|
||||
import { useThemeColor } from "heroui-native";
|
||||
|
||||
const accentColor = useThemeColor("accent");
|
||||
```
|
||||
|
||||
**Theme switching (Light/Dark Mode):**
|
||||
|
||||
```tsx
|
||||
import { Uniwind, useUniwind } from "uniwind";
|
||||
|
||||
const { theme } = useUniwind();
|
||||
Uniwind.setTheme(theme === "light" ? "dark" : "light");
|
||||
```
|
||||
|
||||
For detailed theming, fetch: `https://heroui.com/docs/native/getting-started/theming.mdx`
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get complete component documentation (MDX) for HeroUI Native components.
|
||||
*
|
||||
* Usage:
|
||||
* node get_component_docs.mjs Button
|
||||
* node get_component_docs.mjs Button Card TextField
|
||||
*
|
||||
* Output:
|
||||
* MDX documentation including imports, usage, variants, props, examples
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||
const FALLBACK_BASE = "https://heroui.com";
|
||||
const APP_PARAM = "app=native-skills";
|
||||
|
||||
/**
|
||||
* Convert PascalCase to kebab-case.
|
||||
*/
|
||||
function toKebabCase(name) {
|
||||
return name
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint, method = "GET", body = null) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "HeroUI-Native-Skill/1.0",
|
||||
},
|
||||
method,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||
*/
|
||||
async function fetchFallback(component) {
|
||||
const kebabName = toKebabCase(component);
|
||||
const url = `${FALLBACK_BASE}/docs/native/components/${kebabName}.mdx`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {component, error: `Failed to fetch docs for ${component}`};
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
component,
|
||||
content,
|
||||
contentType: "mdx",
|
||||
source: "fallback",
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return {component, error: `Failed to fetch docs for ${component}`};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get component documentation.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
|
||||
console.error("Example: node get_component_docs.mjs Button Card");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const components = args;
|
||||
|
||||
// Try API first - use POST /v1/components/docs for batch requests
|
||||
console.error(`# Fetching Native docs for: ${components.join(", ")}...`);
|
||||
const data = await fetchApi("/v1/components/docs", "POST", {components});
|
||||
|
||||
if (data && data.results) {
|
||||
// Output results
|
||||
if (data.results.length === 1) {
|
||||
// Single component - output content directly for easier reading
|
||||
const result = data.results[0];
|
||||
|
||||
if (result.content) {
|
||||
console.log(result.content);
|
||||
} else if (result.error) {
|
||||
console.error(`# Error for ${result.component}: ${result.error}`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
// Multiple components - output as JSON array
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to individual component fetches
|
||||
console.error("# API failed, using fallback...");
|
||||
const results = [];
|
||||
|
||||
for (const component of components) {
|
||||
const result = await fetchFallback(component);
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (results.length === 1) {
|
||||
// Single component - output content directly for easier reading
|
||||
const result = results[0];
|
||||
|
||||
if (result.content) {
|
||||
console.log(result.content);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
// Multiple components - output as JSON array
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get non-component HeroUI Native documentation (guides, theming, releases).
|
||||
*
|
||||
* Usage:
|
||||
* node get_docs.mjs /docs/native/getting-started/theming
|
||||
* node get_docs.mjs /docs/native/releases/beta-12
|
||||
*
|
||||
* Output:
|
||||
* MDX documentation content
|
||||
*
|
||||
* Note: For component docs, use get_component_docs.mjs instead.
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||
const FALLBACK_BASE = "https://heroui.com";
|
||||
const APP_PARAM = "app=native-skills";
|
||||
|
||||
/**
|
||||
* Fetch documentation from HeroUI Native API.
|
||||
* Uses v1 endpoint pattern: /v1/docs/:path
|
||||
*/
|
||||
async function fetchApi(path) {
|
||||
// The v1 API expects path without /docs/ prefix
|
||||
// Input: /docs/native/getting-started/theming
|
||||
// API expects: native/getting-started/theming (route is /v1/docs/:path(*))
|
||||
let apiPath = path.startsWith("/docs/")
|
||||
? path.slice(6) // Remove /docs/ prefix
|
||||
: path.startsWith("/")
|
||||
? path.slice(1) // Remove leading /
|
||||
: path;
|
||||
|
||||
const separator = "?";
|
||||
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||
*/
|
||||
async function fetchFallback(path) {
|
||||
// Ensure path starts with /docs and ends with .mdx
|
||||
let cleanPath = path.replace(/^\//, "");
|
||||
|
||||
if (!cleanPath.endsWith(".mdx")) {
|
||||
cleanPath = `${cleanPath}.mdx`;
|
||||
}
|
||||
|
||||
const url = `${FALLBACK_BASE}/${cleanPath}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {error: `HTTP ${response.status}: ${response.statusText}`, path};
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
content,
|
||||
contentType: "mdx",
|
||||
path,
|
||||
source: "fallback",
|
||||
url,
|
||||
};
|
||||
} catch (error) {
|
||||
return {error: `Fetch Error: ${error.message}`, path};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get documentation for specified path.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_docs.mjs <path>");
|
||||
console.error("Example: node get_docs.mjs /docs/native/getting-started/theming");
|
||||
console.error();
|
||||
console.error("Available paths include:");
|
||||
console.error(" /docs/native/getting-started/theming");
|
||||
console.error(" /docs/native/getting-started/colors");
|
||||
console.error(" /docs/native/getting-started/styling");
|
||||
console.error(" /docs/native/releases/beta-12");
|
||||
console.error();
|
||||
console.error("Note: For component docs, use get_component_docs.mjs instead.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const path = args[0];
|
||||
|
||||
// Check if user is trying to get component docs
|
||||
if (path.includes("/components/")) {
|
||||
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
|
||||
const componentName = path.split("/").pop().replace(".mdx", "");
|
||||
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
|
||||
|
||||
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
|
||||
}
|
||||
|
||||
// Validate Native path
|
||||
if (!path.startsWith("/docs/native/")) {
|
||||
console.error("# Warning: Native documentation paths should start with /docs/native/");
|
||||
console.error(`# Provided path: ${path}`);
|
||||
}
|
||||
|
||||
console.error(`# Fetching Native documentation for ${path}...`);
|
||||
|
||||
// Try API first
|
||||
const data = await fetchApi(path);
|
||||
|
||||
if (data && data.content) {
|
||||
data.source = "api";
|
||||
console.log(data.content);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to direct fetch
|
||||
console.error("# API failed, using fallback...");
|
||||
const fallbackData = await fetchFallback(path);
|
||||
|
||||
if (fallbackData.content) {
|
||||
console.log(fallbackData.content);
|
||||
} else {
|
||||
console.log(JSON.stringify(fallbackData, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get theme variables and design tokens for HeroUI Native.
|
||||
*
|
||||
* Usage:
|
||||
* node get_theme.mjs
|
||||
*
|
||||
* Output:
|
||||
* Theme variables organized by light/dark with HSL color format
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||
const APP_PARAM = "app=native-skills";
|
||||
|
||||
// Fallback theme reference when API is unavailable
|
||||
const FALLBACK_THEME = {
|
||||
borderRadius: {
|
||||
full: 9999,
|
||||
lg: 12,
|
||||
md: 8,
|
||||
sm: 6,
|
||||
},
|
||||
dark: {
|
||||
colors: [
|
||||
{
|
||||
category: "base",
|
||||
name: "--color-background",
|
||||
value: "hsl(0, 0%, 14.5%)",
|
||||
},
|
||||
{
|
||||
category: "semantic",
|
||||
name: "--color-foreground",
|
||||
value: "hsl(0, 0%, 98.4%)",
|
||||
},
|
||||
{
|
||||
category: "semantic",
|
||||
name: "--color-accent",
|
||||
value: "hsl(264.1, 100%, 55.1%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-danger",
|
||||
value: "hsl(25.3, 100%, 63.7%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-success",
|
||||
value: "hsl(163.2, 100%, 76.5%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-warning",
|
||||
value: "hsl(86.0, 100%, 79.5%)",
|
||||
},
|
||||
],
|
||||
},
|
||||
latestVersion: "beta",
|
||||
light: {
|
||||
colors: [
|
||||
{
|
||||
category: "base",
|
||||
name: "--color-background",
|
||||
value: "hsl(0, 0%, 100%)",
|
||||
},
|
||||
{
|
||||
category: "semantic",
|
||||
name: "--color-foreground",
|
||||
value: "hsl(285.89, 5.9%, 21.03%)",
|
||||
},
|
||||
{
|
||||
category: "semantic",
|
||||
name: "--color-accent",
|
||||
value: "hsl(253.83, 100%, 62.04%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-danger",
|
||||
value: "hsl(25.74, 100%, 65.32%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-success",
|
||||
value: "hsl(150.81, 100%, 73.29%)",
|
||||
},
|
||||
{
|
||||
category: "status",
|
||||
name: "--color-warning",
|
||||
value: "hsl(72.33, 100%, 78.19%)",
|
||||
},
|
||||
],
|
||||
},
|
||||
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
|
||||
opacity: {
|
||||
disabled: 0.4,
|
||||
hover: 0.8,
|
||||
pressed: 0.6,
|
||||
},
|
||||
source: "fallback",
|
||||
theme: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format colors grouped by category.
|
||||
*/
|
||||
function formatColors(colors) {
|
||||
const grouped = {};
|
||||
|
||||
for (const color of colors) {
|
||||
const category = color.category || "semantic";
|
||||
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = [];
|
||||
}
|
||||
grouped[category].push(color);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
|
||||
for (const [category, tokens] of Object.entries(grouped)) {
|
||||
lines.push(` /* ${category.charAt(0).toUpperCase() + category.slice(1)} Colors */`);
|
||||
for (const token of tokens) {
|
||||
const name = token.name || "";
|
||||
const value = token.value || "";
|
||||
|
||||
lines.push(` ${name}: ${value};`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get theme variables.
|
||||
*/
|
||||
async function main() {
|
||||
console.error("# Fetching Native theme variables...");
|
||||
|
||||
const rawData = await fetchApi("/v1/themes/variables?theme=default");
|
||||
|
||||
let data;
|
||||
let version;
|
||||
|
||||
if (!rawData) {
|
||||
console.error("# API failed, using fallback theme reference...");
|
||||
data = FALLBACK_THEME;
|
||||
version = FALLBACK_THEME.latestVersion || "unknown";
|
||||
} else {
|
||||
// Handle API response format
|
||||
data = rawData;
|
||||
version = rawData.latestVersion || "unknown";
|
||||
}
|
||||
|
||||
// Output as formatted structure for readability
|
||||
console.log("/* HeroUI Native Theme Variables */");
|
||||
console.log(`/* Theme: ${data.theme || "default"} */`);
|
||||
console.log(`/* Version: ${version} */`);
|
||||
console.log();
|
||||
|
||||
// Light mode colors
|
||||
if (data.light && data.light.colors) {
|
||||
console.log("/* Light Mode Colors */");
|
||||
console.log(formatColors(data.light.colors));
|
||||
}
|
||||
|
||||
// Dark mode colors
|
||||
if (data.dark && data.dark.colors) {
|
||||
console.log("/* Dark Mode Colors */");
|
||||
console.log(formatColors(data.dark.colors));
|
||||
}
|
||||
|
||||
// Border radius
|
||||
if (data.borderRadius) {
|
||||
console.log("/* Border Radius */");
|
||||
for (const [key, value] of Object.entries(data.borderRadius)) {
|
||||
console.log(` --radius-${key}: ${value};`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Opacity
|
||||
if (data.opacity) {
|
||||
console.log("/* Opacity */");
|
||||
for (const [key, value] of Object.entries(data.opacity)) {
|
||||
console.log(` --opacity-${key}: ${value};`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Also output raw JSON to stderr for programmatic use
|
||||
console.error("\n# Raw JSON output:");
|
||||
console.error(JSON.stringify(rawData || data, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* List all available HeroUI Native components.
|
||||
*
|
||||
* Usage:
|
||||
* node list_components.mjs
|
||||
*
|
||||
* Output:
|
||||
* JSON with components array, latestVersion, and count
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
|
||||
const APP_PARAM = "app=native-skills";
|
||||
const LLMS_TXT_URL = "https://heroui.com/native/llms.txt";
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI Native API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch component list from llms.txt fallback URL.
|
||||
*/
|
||||
async function fetchFallback() {
|
||||
try {
|
||||
const response = await fetch(LLMS_TXT_URL, {
|
||||
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// Parse markdown to extract component names from pattern: - [ComponentName](url)
|
||||
// Look for links under the Components section (### Components)
|
||||
const components = [];
|
||||
let inComponentsSection = false;
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
// Check if we're entering the Components section (uses ### header)
|
||||
if (line.trim() === "### Components") {
|
||||
inComponentsSection = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the Components section (another ### header)
|
||||
if (inComponentsSection && line.trim().startsWith("### ")) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract component name from markdown link pattern
|
||||
// Match: - [ComponentName](https://www.heroui.com/docs/native/components/component-name)
|
||||
// Skip "All Components" which links to /components without a specific component
|
||||
if (inComponentsSection) {
|
||||
const match = line.match(
|
||||
/^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/native\/components\/[a-z]/,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
components.push(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (components.length > 0) {
|
||||
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
|
||||
|
||||
return {
|
||||
components: components.sort(),
|
||||
count: components.length,
|
||||
latestVersion: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Fallback Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to list all available HeroUI Native components.
|
||||
*/
|
||||
async function main() {
|
||||
let data = await fetchApi("/v1/components");
|
||||
|
||||
// Check if API returned valid data with components
|
||||
if (!data || !data.components || data.components.length === 0) {
|
||||
console.error("# API returned no components, trying fallback...");
|
||||
data = await fetchFallback();
|
||||
}
|
||||
|
||||
if (!data || !data.components || data.components.length === 0) {
|
||||
console.error("Error: Failed to fetch component list from API and fallback");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output formatted JSON
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
// Print summary to stderr for human readability
|
||||
console.error(
|
||||
`\n# Found ${data.components.length} Native components (${data.latestVersion || "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,199 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an approval
|
||||
from the project maintainers before using the Apache License.
|
||||
|
||||
Copyright 2025 NextUI Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
name: heroui-react
|
||||
description: "HeroUI v3 React component library (Tailwind CSS v4 + React Aria). Use when building UIs with HeroUI — creating Buttons, Modals, Forms, Cards; installing @heroui/react; configuring dark/light themes with oklch variables; or fetching component docs. Keywords: HeroUI, Hero UI, heroui, @heroui/react, @heroui/styles."
|
||||
metadata:
|
||||
author: heroui
|
||||
version: "3.0.1"
|
||||
---
|
||||
|
||||
# HeroUI v3 React Development Guide
|
||||
|
||||
HeroUI v3 is a component library built on **Tailwind CSS v4** and **React Aria Components**, providing accessible, customizable UI components for React applications.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://heroui.com/install | bash -s heroui-react
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: v3 Only - Ignore v2 Knowledge
|
||||
|
||||
**This guide is for HeroUI v3 ONLY.** Do NOT apply v2 patterns — the provider, styling, and component API all changed:
|
||||
|
||||
| Feature | v2 (DO NOT USE) | v3 (USE THIS) |
|
||||
| ------------- | --------------------------------- | ------------------------------------------- |
|
||||
| Provider | `<HeroUIProvider>` required | **No Provider needed** |
|
||||
| Animations | `framer-motion` package | CSS-based, no extra deps |
|
||||
| Component API | Flat props: `<Card title="x">` | Compound: `<Card><Card.Header>` |
|
||||
| Styling | Tailwind v3 + `@heroui/theme` | Tailwind v4 + `@heroui/styles` |
|
||||
| Packages | `@heroui/system`, `@heroui/theme` | `@heroui/react`, `@heroui/styles` |
|
||||
|
||||
```tsx
|
||||
// DO NOT DO THIS - v2 pattern
|
||||
import { HeroUIProvider } from "@heroui/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
<HeroUIProvider>
|
||||
<Card title="Product" description="A great product" />
|
||||
</HeroUIProvider>;
|
||||
```
|
||||
|
||||
### CORRECT (v3 patterns)
|
||||
|
||||
```tsx
|
||||
// DO THIS - v3 pattern (no provider, compound components)
|
||||
import { Card } from "@heroui/react";
|
||||
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title>Product</Card.Title>
|
||||
<Card.Description>A great product</Card.Description>
|
||||
</Card.Header>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
**Always fetch v3 docs before implementing.**
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
|
||||
- Composition over configuration (compound components)
|
||||
- CSS variable-based theming with `oklch` color space
|
||||
- BEM naming convention for predictable styling
|
||||
|
||||
---
|
||||
|
||||
## Accessing Documentation & Component Information
|
||||
|
||||
**For component details, examples, props, and implementation patterns, always fetch documentation:**
|
||||
|
||||
### Using Scripts
|
||||
|
||||
```bash
|
||||
# List all available components
|
||||
node scripts/list_components.mjs
|
||||
|
||||
# Get component documentation (MDX)
|
||||
node scripts/get_component_docs.mjs Button
|
||||
node scripts/get_component_docs.mjs Button Card TextField
|
||||
|
||||
# Get component source code
|
||||
node scripts/get_source.mjs Button
|
||||
|
||||
# Get component CSS styles (BEM classes)
|
||||
node scripts/get_styles.mjs Button
|
||||
|
||||
# Get theme variables
|
||||
node scripts/get_theme.mjs
|
||||
|
||||
# Get non-component docs (guides, releases)
|
||||
node scripts/get_docs.mjs /docs/react/getting-started/theming
|
||||
```
|
||||
|
||||
### Direct MDX URLs
|
||||
|
||||
Component docs: `https://heroui.com/docs/react/components/{component-name}.mdx`
|
||||
|
||||
Examples:
|
||||
|
||||
- Button: `https://heroui.com/docs/react/components/button.mdx`
|
||||
- Modal: `https://heroui.com/docs/react/components/modal.mdx`
|
||||
- Form: `https://heroui.com/docs/react/components/form.mdx`
|
||||
|
||||
Getting started guides: `https://heroui.com/docs/react/getting-started/{topic}.mdx`
|
||||
|
||||
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
|
||||
|
||||
---
|
||||
|
||||
## Installation Essentials
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
npm i @heroui/styles @heroui/react tailwind-variants
|
||||
```
|
||||
|
||||
### Framework Setup (Next.js App Router - Recommended)
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm i @heroui/styles @heroui/react tailwind-variants tailwindcss @tailwindcss/postcss postcss
|
||||
```
|
||||
|
||||
2. **Create/update `app/globals.css`:**
|
||||
|
||||
```css
|
||||
/* Tailwind CSS v4 - Must be first */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* HeroUI v3 styles - Must be after Tailwind */
|
||||
@import "@heroui/styles";
|
||||
```
|
||||
|
||||
3. **Import in `app/layout.tsx`:**
|
||||
|
||||
```tsx
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
{/* No Provider needed in HeroUI v3! */}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Configure PostCSS (`postcss.config.mjs`):**
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Critical Setup Requirements
|
||||
|
||||
1. **Tailwind CSS v4 is MANDATORY** - HeroUI v3 will NOT work with Tailwind CSS v3
|
||||
2. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Content`)
|
||||
3. **Use onPress, not onClick** - For better accessibility, use `onPress` event handlers
|
||||
4. **Import Order Matters** - Always import Tailwind CSS before HeroUI styles
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
All components use the **compound pattern** shown above (dot-notation subcomponents like `Card.Header`, `Card.Content`). Don't flatten to props — always compose with subcomponents. Fetch component docs for complete anatomy and examples.
|
||||
|
||||
---
|
||||
|
||||
## Semantic Variants
|
||||
|
||||
HeroUI uses semantic naming to communicate functional intent:
|
||||
|
||||
| Variant | Purpose | Usage |
|
||||
| ----------- | --------------------------------- | -------------- |
|
||||
| `primary` | Main action to move forward | 1 per context |
|
||||
| `secondary` | Alternative actions | Multiple |
|
||||
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
|
||||
| `danger` | Destructive actions | When needed |
|
||||
| `ghost` | Low-emphasis actions | Minimal weight |
|
||||
| `outline` | Secondary actions | Bordered style |
|
||||
|
||||
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
|
||||
|
||||
---
|
||||
|
||||
## Theming
|
||||
|
||||
HeroUI v3 uses CSS variables with `oklch` color space:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: oklch(0.6204 0.195 253.83);
|
||||
--accent-foreground: var(--snow);
|
||||
--background: oklch(0.9702 0 0);
|
||||
--foreground: var(--eclipse);
|
||||
}
|
||||
```
|
||||
|
||||
**Get current theme variables:**
|
||||
|
||||
```bash
|
||||
node scripts/get_theme.mjs
|
||||
```
|
||||
|
||||
**Color naming:**
|
||||
|
||||
- Without suffix = background (e.g., `--accent`)
|
||||
- With `-foreground` = text color (e.g., `--accent-foreground`)
|
||||
|
||||
**Theme switching:**
|
||||
|
||||
```html
|
||||
<html class="dark" data-theme="dark"></html>
|
||||
```
|
||||
|
||||
For detailed theming, fetch: `https://heroui.com/docs/react/getting-started/theming.mdx`
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get complete component documentation (MDX) for HeroUI v3 components.
|
||||
*
|
||||
* Usage:
|
||||
* node get_component_docs.mjs Button
|
||||
* node get_component_docs.mjs Button Card TextField
|
||||
*
|
||||
* Output:
|
||||
* MDX documentation including imports, usage, variants, props, examples
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const FALLBACK_BASE = "https://heroui.com";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
|
||||
/**
|
||||
* Convert PascalCase to kebab-case.
|
||||
*/
|
||||
function toKebabCase(name) {
|
||||
return name
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint, method = "GET", body = null) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "HeroUI-Skill/1.0",
|
||||
},
|
||||
method,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||
*/
|
||||
async function fetchFallback(component) {
|
||||
const kebabName = toKebabCase(component);
|
||||
const url = `${FALLBACK_BASE}/docs/react/components/${kebabName}.mdx`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {component, error: `Failed to fetch docs for ${component}`};
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
component,
|
||||
content,
|
||||
contentType: "mdx",
|
||||
source: "fallback",
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return {component, error: `Failed to fetch docs for ${component}`};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get component documentation.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
|
||||
console.error("Example: node get_component_docs.mjs Button Card");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const components = args;
|
||||
|
||||
// Try API first - use POST /v1/components/docs for batch requests
|
||||
console.error(`# Fetching docs for: ${components.join(", ")}...`);
|
||||
const data = await fetchApi("/v1/components/docs", "POST", {components});
|
||||
|
||||
if (data && data.results) {
|
||||
// Output results
|
||||
if (data.results.length === 1) {
|
||||
// Single component - output content directly for easier reading
|
||||
const result = data.results[0];
|
||||
|
||||
if (result.content) {
|
||||
console.log(result.content);
|
||||
} else if (result.error) {
|
||||
console.error(`# Error for ${result.component}: ${result.error}`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
// Multiple components - output as JSON array
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to individual component fetches
|
||||
console.error("# API failed, using fallback...");
|
||||
const results = [];
|
||||
|
||||
for (const component of components) {
|
||||
const result = await fetchFallback(component);
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (results.length === 1) {
|
||||
// Single component - output content directly for easier reading
|
||||
const result = results[0];
|
||||
|
||||
if (result.content) {
|
||||
console.log(result.content);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
// Multiple components - output as JSON array
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get non-component HeroUI documentation (guides, theming, releases).
|
||||
*
|
||||
* Usage:
|
||||
* node get_docs.mjs /docs/react/getting-started/theming
|
||||
* node get_docs.mjs /docs/react/releases/v3-0-0-beta-3
|
||||
*
|
||||
* Output:
|
||||
* MDX documentation content
|
||||
*
|
||||
* Note: For component docs, use get_component_docs.mjs instead.
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const FALLBACK_BASE = "https://heroui.com";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
|
||||
/**
|
||||
* Fetch documentation from HeroUI API.
|
||||
* Uses v1 endpoint pattern: /v1/docs/:path
|
||||
*/
|
||||
async function fetchApi(path) {
|
||||
// The v1 API expects path without /docs/ prefix
|
||||
// Input: /docs/react/getting-started/theming
|
||||
// API expects: react/getting-started/theming (route is /v1/docs/:path(*))
|
||||
let apiPath = path.startsWith("/docs/")
|
||||
? path.slice(6) // Remove /docs/ prefix
|
||||
: path.startsWith("/")
|
||||
? path.slice(1) // Remove leading /
|
||||
: path;
|
||||
|
||||
const separator = "?";
|
||||
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MDX directly from v3.heroui.com as fallback.
|
||||
*/
|
||||
async function fetchFallback(path) {
|
||||
// Ensure path starts with /docs and ends with .mdx
|
||||
let cleanPath = path.replace(/^\//, "");
|
||||
|
||||
if (!cleanPath.endsWith(".mdx")) {
|
||||
cleanPath = `${cleanPath}.mdx`;
|
||||
}
|
||||
|
||||
const url = `${FALLBACK_BASE}/${cleanPath}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {error: `HTTP ${response.status}: ${response.statusText}`, path};
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
content,
|
||||
contentType: "mdx",
|
||||
path,
|
||||
source: "fallback",
|
||||
url,
|
||||
};
|
||||
} catch (error) {
|
||||
return {error: `Fetch Error: ${error.message}`, path};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get documentation for specified path.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_docs.mjs <path>");
|
||||
console.error("Example: node get_docs.mjs /docs/react/getting-started/theming");
|
||||
console.error();
|
||||
console.error("Available paths include:");
|
||||
console.error(" /docs/react/getting-started/theming");
|
||||
console.error(" /docs/react/getting-started/colors");
|
||||
console.error(" /docs/react/getting-started/animations");
|
||||
console.error(" /docs/react/releases/v3-0-0-beta-3");
|
||||
console.error();
|
||||
console.error("Note: For component docs, use get_component_docs.mjs instead.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const path = args[0];
|
||||
|
||||
// Check if user is trying to get component docs
|
||||
if (path.includes("/components/")) {
|
||||
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
|
||||
const componentName = path.split("/").pop().replace(".mdx", "");
|
||||
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
|
||||
|
||||
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
|
||||
}
|
||||
|
||||
console.error(`# Fetching documentation for ${path}...`);
|
||||
|
||||
// Try API first
|
||||
const data = await fetchApi(path);
|
||||
|
||||
if (data && data.content) {
|
||||
data.source = "api";
|
||||
console.log(data.content);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to direct fetch
|
||||
console.error("# API failed, using fallback...");
|
||||
const fallbackData = await fetchFallback(path);
|
||||
|
||||
if (fallbackData.content) {
|
||||
console.log(fallbackData.content);
|
||||
} else {
|
||||
console.log(JSON.stringify(fallbackData, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get React/TypeScript source code implementation for HeroUI v3 components.
|
||||
*
|
||||
* Usage:
|
||||
* node get_source.mjs Button
|
||||
* node get_source.mjs Button Accordion Card
|
||||
*
|
||||
* Output:
|
||||
* Full TSX source code with GitHub URL for each component
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/heroui-inc/heroui/refs/heads/v3";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint, method = "GET", body = null) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "HeroUI-Skill/1.0",
|
||||
},
|
||||
method,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch source code directly from GitHub as fallback.
|
||||
*/
|
||||
async function fetchGithubFallback(component) {
|
||||
// Try common patterns for component paths
|
||||
const patterns = [
|
||||
`packages/react/src/components/${component.toLowerCase()}/${component.toLowerCase()}.tsx`,
|
||||
`packages/react/src/components/${component.toLowerCase()}/index.tsx`,
|
||||
];
|
||||
|
||||
for (const path of patterns) {
|
||||
const url = `${GITHUB_RAW_BASE}/${path}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
component,
|
||||
filePath: path,
|
||||
githubUrl: `https://github.com/heroui-inc/heroui/blob/v3/${path}`,
|
||||
source: "fallback",
|
||||
sourceCode: content,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {component, error: `Failed to fetch source for ${component}`};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get source code for specified components.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_source.mjs <Component1> [Component2] ...");
|
||||
console.error("Example: node get_source.mjs Button Accordion");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const components = args;
|
||||
|
||||
// Try API first
|
||||
console.error(`# Fetching source code for: ${components.join(", ")}...`);
|
||||
const data = await fetchApi("/v1/components/source", "POST", {components});
|
||||
|
||||
if (data && data.results) {
|
||||
for (const result of data.results) {
|
||||
result.source = "api";
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (data.results.length === 1) {
|
||||
const result = data.results[0];
|
||||
|
||||
if (result.sourceCode) {
|
||||
console.log(`// File: ${result.filePath || "unknown"}`);
|
||||
console.log(`// GitHub: ${result.githubUrl || "unknown"}`);
|
||||
console.log();
|
||||
console.log(result.sourceCode);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to GitHub direct fetch
|
||||
console.error("# API failed, using GitHub fallback...");
|
||||
const results = [];
|
||||
|
||||
for (const component of components) {
|
||||
const result = await fetchGithubFallback(component);
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if (results.length === 1) {
|
||||
const result = results[0];
|
||||
|
||||
if (result.sourceCode) {
|
||||
console.log(`// File: ${result.filePath || "unknown"}`);
|
||||
console.log(`// GitHub: ${result.githubUrl || "unknown"}`);
|
||||
console.log();
|
||||
console.log(result.sourceCode);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify({results}, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get CSS styles (BEM classes) for HeroUI v3 components.
|
||||
*
|
||||
* Usage:
|
||||
* node get_styles.mjs Button
|
||||
* node get_styles.mjs Button Card Chip
|
||||
*
|
||||
* Output:
|
||||
* CSS file content with BEM classes and GitHub URL for each component
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com/heroui-inc/heroui/refs/heads/v3";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint, method = "GET", body = null) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "HeroUI-Skill/1.0",
|
||||
},
|
||||
method,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CSS styles directly from GitHub as fallback.
|
||||
*/
|
||||
async function fetchGithubFallback(component) {
|
||||
// Try common patterns for style paths
|
||||
const patterns = [
|
||||
`packages/styles/src/components/${component.toLowerCase()}.css`,
|
||||
`packages/styles/components/${component.toLowerCase()}.css`,
|
||||
];
|
||||
|
||||
for (const path of patterns) {
|
||||
const url = `${GITHUB_RAW_BASE}/${path}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const content = await response.text();
|
||||
|
||||
return {
|
||||
component,
|
||||
filePath: path,
|
||||
githubUrl: `https://github.com/heroui-inc/heroui/blob/v3/${path}`,
|
||||
source: "fallback",
|
||||
stylesCode: content,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {component, error: `Failed to fetch styles for ${component}`};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get CSS styles for specified components.
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: node get_styles.mjs <Component1> [Component2] ...");
|
||||
console.error("Example: node get_styles.mjs Button Card");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const components = args;
|
||||
|
||||
// Try API first
|
||||
console.error(`# Fetching styles for: ${components.join(", ")}...`);
|
||||
const data = await fetchApi("/v1/components/styles", "POST", {components});
|
||||
|
||||
if (data && data.results) {
|
||||
for (const result of data.results) {
|
||||
result.source = "api";
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (data.results.length === 1) {
|
||||
const result = data.results[0];
|
||||
|
||||
if (result.stylesCode) {
|
||||
console.log(`/* File: ${result.filePath || "unknown"} */`);
|
||||
console.log(`/* GitHub: ${result.githubUrl || "unknown"} */`);
|
||||
console.log();
|
||||
console.log(result.stylesCode);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to GitHub direct fetch
|
||||
console.error("# API failed, using GitHub fallback...");
|
||||
const results = [];
|
||||
|
||||
for (const component of components) {
|
||||
const result = await fetchGithubFallback(component);
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if (results.length === 1) {
|
||||
const result = results[0];
|
||||
|
||||
if (result.stylesCode) {
|
||||
console.log(`/* File: ${result.filePath || "unknown"} */`);
|
||||
console.log(`/* GitHub: ${result.githubUrl || "unknown"} */`);
|
||||
console.log();
|
||||
console.log(result.stylesCode);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify({results}, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get theme variables and design tokens for HeroUI v3.
|
||||
*
|
||||
* Usage:
|
||||
* node get_theme.mjs
|
||||
*
|
||||
* Output:
|
||||
* Theme variables organized by common/light/dark with oklch color format
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
|
||||
// Fallback theme reference when API is unavailable
|
||||
const FALLBACK_THEME = {
|
||||
common: {
|
||||
base: [
|
||||
{name: "--font-sans", value: "ui-sans-serif, system-ui, sans-serif"},
|
||||
{name: "--font-mono", value: "ui-monospace, monospace"},
|
||||
{name: "--radius-sm", value: "0.375rem"},
|
||||
{name: "--radius-md", value: "0.5rem"},
|
||||
{name: "--radius-lg", value: "0.75rem"},
|
||||
{name: "--radius-full", value: "9999px"},
|
||||
],
|
||||
calculated: [{name: "--spacing-unit", value: "0.25rem"}],
|
||||
},
|
||||
dark: {
|
||||
semantic: [
|
||||
{name: "--color-background", value: "oklch(14.5% 0 0)"},
|
||||
{name: "--color-foreground", value: "oklch(98.4% 0 0)"},
|
||||
{name: "--color-accent", value: "oklch(55.1% 0.228 264.1)"},
|
||||
{name: "--color-danger", value: "oklch(63.7% 0.237 25.3)"},
|
||||
{name: "--color-success", value: "oklch(76.5% 0.177 163.2)"},
|
||||
{name: "--color-warning", value: "oklch(79.5% 0.184 86.0)"},
|
||||
],
|
||||
},
|
||||
latestVersion: "3.0.0-beta",
|
||||
light: {
|
||||
semantic: [
|
||||
{name: "--color-background", value: "oklch(100% 0 0)"},
|
||||
{name: "--color-foreground", value: "oklch(14.5% 0 0)"},
|
||||
{name: "--color-accent", value: "oklch(55.1% 0.228 264.1)"},
|
||||
{name: "--color-danger", value: "oklch(63.7% 0.237 25.3)"},
|
||||
{name: "--color-success", value: "oklch(76.5% 0.177 163.2)"},
|
||||
{name: "--color-warning", value: "oklch(79.5% 0.184 86.0)"},
|
||||
],
|
||||
},
|
||||
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
|
||||
source: "fallback",
|
||||
theme: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`# API Error: HTTP ${response.status}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`# API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format theme variables for display.
|
||||
*/
|
||||
function formatVariables(variables) {
|
||||
const lines = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
const name = variable.name || "";
|
||||
const value = variable.value || "";
|
||||
const desc = variable.description || "";
|
||||
|
||||
if (desc) {
|
||||
lines.push(` ${name}: ${value}; /* ${desc} */`);
|
||||
} else {
|
||||
lines.push(` ${name}: ${value};`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to get theme variables.
|
||||
*/
|
||||
async function main() {
|
||||
console.error("# Fetching theme variables...");
|
||||
|
||||
const rawData = await fetchApi("/v1/themes/variables?theme=default");
|
||||
|
||||
let data;
|
||||
let version;
|
||||
|
||||
if (!rawData) {
|
||||
console.error("# API failed, using fallback theme reference...");
|
||||
data = FALLBACK_THEME;
|
||||
version = FALLBACK_THEME.latestVersion || "unknown";
|
||||
} else {
|
||||
// Handle API response format: { themes: [...], latestVersion: "..." }
|
||||
if (rawData.themes && rawData.themes.length > 0) {
|
||||
data = rawData.themes[0]; // Get first theme (default)
|
||||
version = rawData.latestVersion || rawData.version || "unknown";
|
||||
} else {
|
||||
// Direct format
|
||||
data = rawData;
|
||||
version = rawData.latestVersion || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Output as formatted CSS-like structure for readability
|
||||
console.log("/* HeroUI v3 Theme Variables */");
|
||||
console.log(`/* Theme: ${data.theme || "default"} */`);
|
||||
console.log(`/* Version: ${version} */`);
|
||||
console.log();
|
||||
|
||||
// Common variables
|
||||
if (data.common) {
|
||||
console.log(":root {");
|
||||
console.log(" /* Base Variables */");
|
||||
if (data.common.base) {
|
||||
console.log(formatVariables(data.common.base));
|
||||
}
|
||||
console.log();
|
||||
console.log(" /* Calculated Variables */");
|
||||
if (data.common.calculated) {
|
||||
console.log(formatVariables(data.common.calculated));
|
||||
}
|
||||
console.log("}");
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Light mode
|
||||
if (data.light) {
|
||||
console.log(":root, [data-theme='light'] {");
|
||||
console.log(" /* Light Mode Semantic Variables */");
|
||||
if (data.light.semantic) {
|
||||
console.log(formatVariables(data.light.semantic));
|
||||
}
|
||||
console.log("}");
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
if (data.dark) {
|
||||
console.log("[data-theme='dark'] {");
|
||||
console.log(" /* Dark Mode Semantic Variables */");
|
||||
if (data.dark.semantic) {
|
||||
console.log(formatVariables(data.dark.semantic));
|
||||
}
|
||||
console.log("}");
|
||||
}
|
||||
|
||||
// Also output raw JSON to stderr for programmatic use
|
||||
console.error("\n# Raw JSON output:");
|
||||
console.error(JSON.stringify(rawData || data, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* List all available HeroUI v3 components.
|
||||
*
|
||||
* Usage:
|
||||
* node list_components.mjs
|
||||
*
|
||||
* Output:
|
||||
* JSON with components array, latestVersion, and count
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.HEROUI_API_BASE || "https://mcp-api.heroui.com";
|
||||
const APP_PARAM = "app=react-skills";
|
||||
const LLMS_TXT_URL = "https://heroui.com/react/llms.txt";
|
||||
|
||||
/**
|
||||
* Fetch data from HeroUI API with app parameter for analytics.
|
||||
*/
|
||||
async function fetchApi(endpoint) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch component list from llms.txt fallback URL.
|
||||
*/
|
||||
async function fetchFallback() {
|
||||
try {
|
||||
const response = await fetch(LLMS_TXT_URL, {
|
||||
headers: {"User-Agent": "HeroUI-Skill/1.0"},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
// Parse markdown to extract component names from pattern: - [ComponentName](url)
|
||||
// Look for links under the Components section (### Components)
|
||||
const components = [];
|
||||
let inComponentsSection = false;
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
// Check if we're entering the Components section (uses ### header)
|
||||
if (line.trim() === "### Components") {
|
||||
inComponentsSection = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the Components section (another ### header)
|
||||
if (inComponentsSection && line.trim().startsWith("### ")) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract component name from markdown link pattern
|
||||
// Match: - [ComponentName](https://www.heroui.com/docs/react/components/component-name)
|
||||
// Skip "All Components" which links to /components without a specific component
|
||||
if (inComponentsSection) {
|
||||
const match = line.match(
|
||||
/^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/react\/components\/[a-z]/,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
components.push(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (components.length > 0) {
|
||||
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
|
||||
|
||||
return {
|
||||
components: components.sort(),
|
||||
count: components.length,
|
||||
latestVersion: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Fallback Error: ${error.message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to list all available HeroUI v3 components.
|
||||
*/
|
||||
async function main() {
|
||||
let data = await fetchApi("/v1/components");
|
||||
|
||||
// Check if API returned valid data with components
|
||||
if (!data || !data.components || data.components.length === 0) {
|
||||
console.error("# API returned no components, trying fallback...");
|
||||
data = await fetchFallback();
|
||||
}
|
||||
|
||||
if (!data || !data.components || data.components.length === 0) {
|
||||
console.error("Error: Failed to fetch component list from API and fallback");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output formatted JSON
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
// Print summary to stderr for human readability
|
||||
console.error(
|
||||
`\n# Found ${data.components.length} components (${data.latestVersion || "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/heroui-migration
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/heroui-native
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/heroui-react
|
||||
@@ -6,6 +6,16 @@
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"XcodeBuildMCP": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"xcodebuildmcp@latest",
|
||||
"mcp"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+284
@@ -7,6 +7,290 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.14.1] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
|
||||
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
|
||||
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
|
||||
failed), the dialog silently closed without pushing. The dialogs now await the link check before
|
||||
deciding, and an empty change summary can no longer be sent
|
||||
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
|
||||
|
||||
### Changed
|
||||
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
|
||||
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
|
||||
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
|
||||
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
|
||||
|
||||
## [0.14.0] — 2026-07-13
|
||||
|
||||
### Changed
|
||||
- **Patients page filter moved to its own row.** The status filter left the toolbar and now sits on a
|
||||
dedicated **"Filter"** row directly above the table, so the header stays a clean title + search +
|
||||
"Add patient" + `⋯` cluster (`frontend/components/patients/patients-view.tsx`).
|
||||
- **Patient detail sheet header.** The patient name, status badge, and the `⋯` menu now share one
|
||||
left-aligned row, and the standalone **Edit** button moved to be the first item inside the `⋯` menu
|
||||
(`frontend/components/patients/patient-detail.tsx`).
|
||||
- **Settings redesigned with the COSS frame surface.** Every settings section now renders in a
|
||||
`CardFrame` (header + body) via a new `SettingsFrame` part, replacing the flat `SettingsCard` divs
|
||||
(`frontend/components/settings/settings-parts.tsx`, `settings-ai.tsx`).
|
||||
|
||||
### Added
|
||||
- **AI Mode: Automatic and Off.** Settings → AI gains two modes beyond API / Local: **Automatic**
|
||||
(use a cloud API key when set, else fall back to local Ollama) and **Off** (assistant disabled).
|
||||
Automatic is the new default, so a fresh install shows the setup banner until a provider is wired
|
||||
(`frontend/lib/ai-settings.ts`, `backend/src/services/ai/{config,provider}.ts`, `types/ai.ts`).
|
||||
|
||||
### Fixed
|
||||
- **AI setup banner now appears when no provider is configured.** Previously the defaulted Ollama URL
|
||||
counted as "configured," so the "connect an AI model" banner never showed; it now reflects the
|
||||
actual mode (`frontend/components/chat/ai-setup-notice.tsx`).
|
||||
- **Chat cards no longer silently vanish.** An unrecognized/renamed streamed data part now renders a
|
||||
small placeholder instead of nothing (`frontend/components/chat/chat-panel.tsx`).
|
||||
|
||||
### Wallet app
|
||||
- **Documents/files reach the wallet.** Clinic record-update pushes now include attachment metadata,
|
||||
which the wallet folds into the record so the **Documents** tile counts them and the Documents
|
||||
screen lists them (`backend/src/services/wallet-updates.ts`, `temetro-app` types + home/documents).
|
||||
- **Failed pushes are no longer swallowed.** A record update that fails signature verification or
|
||||
decoding is now logged (and a "couldn't verify" notice is raised) instead of disappearing silently.
|
||||
- **i18next translation.** The wallet app now uses `i18next` + `react-i18next` + `expo-localization`
|
||||
(English shipped; other locales can be added later), with the home, record-update inbox, navigation
|
||||
titles, and the visits/prescriptions-adjacent detail screens extracted to translation keys.
|
||||
|
||||
## [0.13.1] — 2026-07-13
|
||||
|
||||
### Changed
|
||||
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
|
||||
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
|
||||
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
|
||||
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
|
||||
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
|
||||
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
|
||||
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
|
||||
a destructive Delete).
|
||||
|
||||
### Wallet app
|
||||
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
|
||||
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
|
||||
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
|
||||
already has a bell) is replaced with **Scan**.
|
||||
|
||||
## [0.13.0] — 2026-07-12
|
||||
|
||||
### Added
|
||||
- **Prescription date pickers.** The New Prescription dialog's Start/End date now use a proper COSS
|
||||
date picker (`frontend/components/ui/date-picker.tsx`, Popover + Calendar) instead of the raw
|
||||
native date input.
|
||||
- **Working profile fields.** Settings → Profile **Specialty** (a Select) and **Professional links**
|
||||
(editable rows) are now wired to persisted preferences — previously they were inert stubs.
|
||||
- **Patient filters.** The Patients page gains a status filter (all / active / inpatient /
|
||||
discharged) and its search now also matches conditions and allergies.
|
||||
- **Language quick-switch.** A language submenu in the sidebar user menu (alongside Theme), applied
|
||||
immediately with Arabic RTL.
|
||||
- **Wallet app — home & settings.** The patient app home screen adds a quick-action row (Share
|
||||
record / My wallet / Notifications) and a recent-activity feed; the settings screen adds an About
|
||||
section (version, docs, blog) and a privacy footer.
|
||||
|
||||
### Changed
|
||||
- **Record history renders in full.** The patient sheet's Record history replaces the fragile
|
||||
timeline-separator layout with a continuous-rail list, so every audited change shows completely
|
||||
(and mirrors correctly under RTL).
|
||||
- **Update banner + AI setup notice.** The "update available" banner now uses the COSS warning Alert
|
||||
and stays bottom-right under Arabic; the "connect an AI provider" notice is a clearer, thinner bar
|
||||
shown above the chat input in active conversations too.
|
||||
- **Wallet app — bottom sheets.** Polished the shared sheet building blocks (spacing, radii, close
|
||||
and action affordances).
|
||||
|
||||
### Fixed
|
||||
- **Blog reachable.** `blog.temetro.com` was returning 502 due to a custom-domain target-port
|
||||
mismatch (Ghost listens on 2368); documented and corrected.
|
||||
- **Docs freshness.** Corrected stale `CLAUDE.md` claims (the AI chat is real and `@ai-sdk/react` is
|
||||
installed; the signing/approval flow is built) and reduced the vendored `ai-elements` Base UI
|
||||
type-drift errors.
|
||||
|
||||
## [0.12.1] — 2026-07-10
|
||||
|
||||
### Changed
|
||||
- **Centered wallet-sync stepper.** The in-dialog "Sync to wallet" stepper (`DialogStepper` in
|
||||
`frontend/components/wallet/wallet-sync-step.tsx`) now uses a proper Stepper primitive
|
||||
(`components/ui/stepper.tsx`), so the numbered indicators sit centered inline with their labels
|
||||
instead of the previous left-aligned look.
|
||||
- **Record history is now a timeline.** The patient sheet's **Record history** section
|
||||
(`RecordHistory` in `frontend/components/patients/patient-detail.tsx`) renders as a vertical
|
||||
timeline (`components/ui/timeline.tsx`) — who made the change, an entity-type icon, what happened,
|
||||
and when — replacing the flat avatar list.
|
||||
|
||||
### Performance
|
||||
- **Landing page: defer the 3D globe.** The Temetro Network globe (three.js) on the marketing site
|
||||
now mounts only when its section scrolls near the viewport (IntersectionObserver), instead of on
|
||||
hydration — removing ~730 KB of JS and its main-thread execution from initial page load. (Landing
|
||||
page lives in the sibling `temetro/landing-page` repo.)
|
||||
|
||||
## [0.12.0] — 2026-07-09
|
||||
|
||||
### Added
|
||||
- **In-dialog "Sync to wallet" stepper.** When a clinician adds or edits a record for a
|
||||
wallet-linked patient — invoice, appointment, prescription, patient demographics, or an AI
|
||||
scribe note — the create/edit dialog shows a two-step stepper: after saving, step 2 offers to
|
||||
push the change to the patient's wallet (reusing `pushWalletUpdate` + approval polling). Shared
|
||||
`useWalletSync` hook and `DialogStepper` / `WalletSyncStep` components under `components/wallet/`.
|
||||
Patients without a linked wallet see the old close-on-save behaviour.
|
||||
|
||||
### Changed
|
||||
- **Appointment & invoice date pickers block past dates.** The new-appointment date picker disables
|
||||
days before today; the invoice issue-date picker does too, with an opt-in **Back-date** checkbox
|
||||
for recording genuinely older invoices (edit mode keeps existing past dates).
|
||||
- **Patient Portal wallet link is identified by wallet number only.** The portal `link` action no
|
||||
longer asks the wallet app for a name + file number — it resolves the file the clinic already
|
||||
paired the wallet number to (`services/portal.ts#linkWallet`), returning a friendly 404 when the
|
||||
wallet isn't paired yet.
|
||||
|
||||
### Removed
|
||||
- **Stale Settings "Features" section.** Dropped the inert "patient-owned storage" / "require signed
|
||||
records" toggles (and their unused i18n keys) that did nothing.
|
||||
|
||||
## [0.11.0] — 2026-07-09
|
||||
|
||||
### Added
|
||||
- **Patient Portal over the Temetro Network relay + wallet linking.** The wallet app now reaches a
|
||||
clinic's Patient Portal through the relay instead of a direct HTTP API, so it works from a real
|
||||
phone. New `services/portal.ts` (clinic info, doctors, availability, wallet linking, conflict-aware
|
||||
booking, results, downloadable lab files) runs behind a `portal:request` hub handler
|
||||
(`backend/src/services/relay-client.ts`). A new nullable `patients.wallet_number` column stores the
|
||||
link, and `walletNumberForPatient` resolves through it so clinic→wallet pushes work after a portal
|
||||
link (not only after a permanent share). `GET /api/portal/:clinic/link` returns the relay-based
|
||||
pairing descriptor (clinic signing key + relay URL).
|
||||
- **Portal "Link my wallet" option.** The Patient Portal kiosk adds a third card that shows a QR the
|
||||
wallet app scans to link over the relay (`components/portal/portal-kiosk.tsx`).
|
||||
- **Appointments & invoices reach the wallet.** Clinic→wallet pushes now include the patient's
|
||||
appointments and invoices in the sealed bundle, so they show up in the wallet app.
|
||||
- **Clinic location reverse-geocoding.** "Use my current location" now fills address / city /
|
||||
country (OpenStreetMap Nominatim), not just latitude / longitude (`lib/geocode.ts`).
|
||||
|
||||
### Fixed
|
||||
- **Patient Portal QR was unreachable from a phone.** Settings → Signing now encodes a
|
||||
`temetro-portal:` pairing URI (relay URL + clinic signing key) instead of a `localhost` API URL, so
|
||||
the wallet app can actually connect (`components/settings/settings-portal.tsx`).
|
||||
- **Arabic (RTL) sidebar.** The collapse arrow and notification bell now stack **above** the nav
|
||||
icons instead of being pinned to the opposite edge; the toggle glyph mirrors and the notifications
|
||||
popover opens toward the content side (`components/sidebar-02/app-sidebar.tsx`,
|
||||
`components/ui/sidebar.tsx`, `components/sidebar-02/nav-notifications.tsx`).
|
||||
|
||||
## [0.10.0] — 2026-07-07
|
||||
|
||||
### Added
|
||||
- **Patient Portal doctor picker & availability.** The portal now lists the clinic's doctors and
|
||||
books against a chosen provider, showing only free slots. New public endpoints
|
||||
`GET /api/portal/:clinic/doctors` and `GET /api/portal/:clinic/availability?provider=&date=`
|
||||
(display-safe fields only), and `POST /api/portal/:clinic/appointments` accepts an optional
|
||||
`provider` (`backend/src/routes/portal.ts`). The existing 409 conflict check stays authoritative.
|
||||
- **Patient Portal links in Settings.** Settings → Signing → Patient Portal adds **open**, **copy
|
||||
link**, and **QR code** actions (`components/settings/settings-portal.tsx`); the QR carries the
|
||||
backend base (`?api=`) so the patient wallet app can book natively when it scans it.
|
||||
- **Clinic location "Use my current location".** The location editor fills map coordinates from the
|
||||
browser's geolocation (`components/settings/settings-location.tsx`).
|
||||
- **Wallet app native Patient Portal.** Scanning a clinic's portal QR opens a native booking screen
|
||||
(doctor list → free-slot picker → confirm) in the patient wallet app.
|
||||
|
||||
### Fixed
|
||||
- **Arabic (RTL) layout.** The sidebar now anchors to the **right** for RTL locales and the toggle
|
||||
switch mirrors correctly, instead of leaving the shell misaligned
|
||||
(`components/sidebar-02/app-sidebar.tsx`, `components/ui/switch.tsx`).
|
||||
- **Wallet app:** record-card **bottom sheet no longer freezes** the app (dropped the per-frame
|
||||
animated blur overlay for HeroUI's built-in overlay); **Reset wallet** now confirms in a native
|
||||
HeroUI dialog with Liquid Glass actions; fixed the **white edge flash** on screen transitions in
|
||||
dark mode; the home/onboarding/register **logo** is now the Temetro mark.
|
||||
|
||||
### Changed
|
||||
- New i18n keys (`settings.portal.*`, geolocation strings) are translated into **all** shipped
|
||||
locales (en, de, fr, ar, so).
|
||||
|
||||
## [0.9.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- **Patient blood type & phone number.** The patient record now carries a `bloodType` (e.g. `O+`)
|
||||
and a `phone` number. Both are shown in the record sheet and chat summary card and are editable in
|
||||
the add/edit patient form. `phone` is a demographic/contact field (visible to and editable by the
|
||||
**reception** role); `bloodType` is treated as clinical PHI and is **redacted for reception** (like
|
||||
allergies/vitals). New columns `patients.phone` / `patients.blood_type` (migration `0033`).
|
||||
- **Clinic location setting.** A new org-scoped `clinic_settings` table (migration `0034`) stores the
|
||||
clinic's address (address / city / country) plus optional map coordinates (latitude / longitude),
|
||||
set in **Settings → Signing → Clinic location** (owner/admin only). New endpoints
|
||||
`GET /api/clinic/settings` (any clinician) and `PUT /api/clinic/location` (owner/admin). This will
|
||||
be surfaced in the patient wallet app to show a clinic's location.
|
||||
|
||||
### Changed
|
||||
- New i18n keys for the above are translated into **all** shipped locales (en, de, fr, ar, so), per
|
||||
the coverage rule now documented in `frontend/CLAUDE.md`.
|
||||
|
||||
## [0.8.2] — 2026-07-05
|
||||
|
||||
### Fixed
|
||||
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
|
||||
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
|
||||
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
|
||||
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
|
||||
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
|
||||
`RELAY_URL`. Updated `.env.example` accordingly.
|
||||
|
||||
### Changed
|
||||
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
|
||||
connected before pre-registering the request, so the routing is set up even if the connection was
|
||||
opened lazily.
|
||||
|
||||
### Fixed
|
||||
- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing
|
||||
has no wallet number, so the clinic never sent a `wallet:send` to register the request, and the
|
||||
relay rejected the scanning device's response as "unknown or expired". The clinic now
|
||||
**pre-registers** the pairing request with the relay (a new `hub:expect { requestId }` event on
|
||||
`POST /api/patients/wallet/pair`), so the device's response routes back correctly. On hub
|
||||
(re)connect the backend re-registers its still-pending requests, so routing also survives a relay
|
||||
restart. `POST /pair` now also requires the clinic to have joined the network (clear 409 instead of
|
||||
a dead QR), surfaced in the import dialog.
|
||||
|
||||
### Added
|
||||
- **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each
|
||||
clinic authenticates to the `/hub` namespace by **signing a challenge with its own Ed25519 clinic
|
||||
signing key** (`services/signing.ts`) — a per-clinic identity, not a shared password — and the
|
||||
relay routes every device response back to only the clinic that originated the request (keyed by
|
||||
`requestId`), so clinics never see each other's traffic. `wallet:online` is fanned out only to
|
||||
clinics with pending work for that wallet.
|
||||
- **"Join Temetro Network" opt-in.** A per-clinic toggle in **Settings → Signing** (backed by
|
||||
`clinic_signing_keys.network_enabled`, `GET`/`PUT /api/signing/network`, owner/admin only). Off by
|
||||
default; enabling opens the clinic's relay connection, disabling tears it down. Wallet
|
||||
import/push endpoints return **409** while a clinic hasn't joined. Localised in all five languages.
|
||||
|
||||
### Changed
|
||||
- **The backend keeps one authenticated relay connection per network-enabled org**
|
||||
(`services/relay-client.ts` — `connectOrg`/`disconnectOrg`, a `hubs` map keyed by `orgId`), instead
|
||||
of a single shared-token connection. `emitToWallet`/`sendToWallet` now take an `orgId`, and the
|
||||
offline-flush (`pendingUpdatesForWallet`) is org-scoped.
|
||||
- **`RELAY_TOKEN` is now optional/legacy.** Clinics authenticate with their signing key, so an open
|
||||
relay needs no shared secret; `RELAY_TOKEN` only gates an optional *private* relay.
|
||||
|
||||
## [0.7.0] — 2026-07-05
|
||||
|
||||
### Added
|
||||
- **Temetro Network** — a standalone, high-performance **relay** (Rust + Axum + socketioxide) that
|
||||
connects the backend to patient wallet apps, in its own repo
|
||||
([github.com/temetro/temetro-network](https://github.com/temetro/temetro-network)) and deployable
|
||||
on Railway. It replaces the flaky Cloudflare quick-tunnel that used to expose the backend's
|
||||
embedded `/wallet` Socket.io namespace to phones. The relay is a **dumb, stateless pipe**: a
|
||||
`/wallet` namespace for devices (challenge/Ed25519-signature auth, room keyed by wallet number)
|
||||
and a `RELAY_TOKEN`-authenticated `/hub` namespace for the backend. It forwards sealed ciphertext
|
||||
verbatim, keeps no database, and its only crypto is verifying a device's auth signature (proven
|
||||
byte-for-byte compatible with `wallet-crypto.ts`).
|
||||
|
||||
### Changed
|
||||
- **The backend is now a client of the relay, not the wallet server.** The `/wallet` Socket.io
|
||||
namespace was removed from `src/realtime.ts`; a new `src/services/relay-client.ts` connects to the
|
||||
relay's `/hub` (`emitToWallet` delegates to its `sendToWallet`), handles device responses
|
||||
(`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) and flushes missed updates on
|
||||
`wallet:online` — calling the same `wallet-share` / `wallet-updates` services as before. New
|
||||
`RELAY_URL` + `RELAY_TOKEN` env vars; the wallet-import QR now points at `RELAY_URL`.
|
||||
|
||||
## [0.6.0] — 2026-07-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,12 +27,16 @@ repository (published as `temetro`).
|
||||
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
|
||||
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
|
||||
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
|
||||
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
|
||||
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record
|
||||
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
|
||||
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
>
|
||||
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
|
||||
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
|
||||
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
> **Also built:** the **AI chat is real** — the frontend streams from the backend's tool-using
|
||||
> agent (`POST /api/chat`), not mock replies (see `frontend/CLAUDE.md`).
|
||||
>
|
||||
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
|
||||
> shares. Email verification is wired but currently **not enforced** at sign-in (see
|
||||
> `backend/CLAUDE.md`).
|
||||
|
||||
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
|
||||
|
||||
@@ -47,6 +51,28 @@ here means keys + data live on the patient's device and the relay only ever forw
|
||||
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
|
||||
deleted). Commit/push that app inside its own repo, separately from this one.
|
||||
|
||||
## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`)
|
||||
|
||||
The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the
|
||||
Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not**
|
||||
in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g.
|
||||
on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's
|
||||
embedded `/wallet` Socket.io namespace to phones.
|
||||
|
||||
It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices
|
||||
(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend
|
||||
(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed
|
||||
ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a
|
||||
device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a
|
||||
`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet`
|
||||
now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo,
|
||||
separately from this one.
|
||||
|
||||
> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration
|
||||
> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it
|
||||
> in an accessible copy and mirror the tree in with `tar`; drive git there via
|
||||
> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd.
|
||||
|
||||
## Layout
|
||||
|
||||
`frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged
|
||||
|
||||
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://hub.docker.com/u/khalidxv)
|
||||
[](./CHANGELOG.md)
|
||||
[](./CHANGELOG.md)
|
||||
|
||||

|
||||
|
||||
|
||||
+17
-7
@@ -32,13 +32,23 @@ POSTGRES_PORT=5432
|
||||
BACKEND_PORT=4000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# --- Patient wallet relay -------------------------------------------------
|
||||
# The URL baked into the QR a patient scans to import their record. Their phone
|
||||
# must be able to reach it — so localhost will NOT work from a real device. If
|
||||
# unset, the backend derives it from the request host (fine when you open the
|
||||
# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it
|
||||
# explicitly to a phone-reachable address: your machine's LAN IP or a public
|
||||
# tunnel URL.
|
||||
# --- Temetro Network relay ------------------------------------------------
|
||||
# The standalone relay (github.com/temetro/temetro-network) that connects this
|
||||
# backend to patient phones. Deploy it (e.g. on Railway) and point both this
|
||||
# backend and the wallet app at it. RELAY_URL is the relay's public URL (also
|
||||
# baked into the QR a patient scans). This clinic authenticates to the relay's
|
||||
# /hub with its own Ed25519 signing key, so no shared secret is needed.
|
||||
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
|
||||
# gates on a shared token (then use the SAME value here and on the relay).
|
||||
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
|
||||
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
|
||||
# your own relay. Do NOT use http://localhost — inside Docker that's the
|
||||
# container itself and the relay connection will silently fail.
|
||||
RELAY_URL=https://network.temetro.com
|
||||
RELAY_TOKEN=
|
||||
|
||||
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
|
||||
# using the Temetro Network relay. RELAY_URL takes precedence over this.
|
||||
# PUBLIC_RELAY_URL=http://192.168.1.20:4000
|
||||
|
||||
# --- Email (optional) -----------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,18 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
|
||||
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
|
||||
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
|
||||
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
|
||||
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
|
||||
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
|
||||
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
|
||||
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
|
||||
authenticates by signing the relay's `hub:challenge` with its clinic signing key
|
||||
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
|
||||
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
|
||||
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
|
||||
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
|
||||
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
|
||||
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
|
||||
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
|
||||
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
|
||||
|
||||
## Gotchas / conventions
|
||||
|
||||
@@ -68,6 +68,11 @@ services:
|
||||
BETTER_AUTH_URL: http://localhost:4000
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
PORT: "4000"
|
||||
# Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL
|
||||
# to your deployed relay's public URL and RELAY_TOKEN to the shared secret
|
||||
# you configured on it — both are required for patient-wallet import.
|
||||
RELAY_URL: ${RELAY_URL:-}
|
||||
RELAY_TOKEN: ${RELAY_TOKEN:-}
|
||||
NODE_ENV: production
|
||||
# Uploaded patient/lab files live here, on the temetro_uploads volume.
|
||||
UPLOAD_DIR: /var/lib/temetro/uploads
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "patients" ADD COLUMN "phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "patients" ADD COLUMN "blood_type" text DEFAULT '' NOT NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "clinic_settings" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"address" text DEFAULT '' NOT NULL,
|
||||
"city" text DEFAULT '' NOT NULL,
|
||||
"country" text DEFAULT '' NOT NULL,
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "clinic_settings" ADD CONSTRAINT "clinic_settings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "patients" ADD COLUMN "wallet_number" text;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,34 @@
|
||||
"when": 1783117115021,
|
||||
"tag": "0031_stiff_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1783263738631,
|
||||
"tag": "0032_closed_dakota_north",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1783362745730,
|
||||
"tag": "0033_ambitious_reavers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1783363217049,
|
||||
"tag": "0034_chunky_blacklash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1783530491321,
|
||||
"tag": "0035_slippery_retro_girl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+60
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temetro-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.84",
|
||||
@@ -28,6 +28,7 @@
|
||||
"nodemailer": "^8.0.10",
|
||||
"pg": "^8.21.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3339,6 +3340,40 @@
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
@@ -4947,6 +4982,21 @@
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
@@ -5779,6 +5829,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.6.0",
|
||||
"version": "0.14.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
@@ -41,6 +41,7 @@
|
||||
"nodemailer": "^8.0.10",
|
||||
"pg": "^8.21.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { doublePrecision, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
// Per-clinic (organization) settings. Currently holds the clinic's physical
|
||||
// location — a free-text address plus optional map coordinates — set in
|
||||
// Settings → Location by an owner/admin and surfaced to patients in the wallet
|
||||
// app later (e.g. a map pin for a clinic that shared a record). One row per org
|
||||
// (PK = organizationId), mirroring `clinic_signing_keys`.
|
||||
export const clinicSettings = pgTable("clinic_settings", {
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
address: text("address").notNull().default(""),
|
||||
city: text("city").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
// Optional map coordinates (WGS84). Null until the clinic sets them.
|
||||
latitude: doublePrecision("latitude"),
|
||||
longitude: doublePrecision("longitude"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export * from "./integrations.js";
|
||||
export * from "./staff-profile.js";
|
||||
export * from "./meetings.js";
|
||||
export * from "./signing.js";
|
||||
export * from "./clinic-settings.js";
|
||||
export * from "./wallet-share.js";
|
||||
export * from "./wallet-updates.js";
|
||||
export * from "./fhir-keys.js";
|
||||
|
||||
@@ -36,6 +36,11 @@ export const patients = pgTable(
|
||||
pcp: text("pcp").notNull(),
|
||||
status: text("status").$type<PatientStatus>().notNull(),
|
||||
initials: text("initials").notNull(),
|
||||
// Contact + clinical demographics. `phone` is a contact/registration field
|
||||
// (reception may read/write it); `bloodType` is clinical (redacted for the
|
||||
// reception role, like allergies/vitals).
|
||||
phone: text("phone").notNull().default(""),
|
||||
bloodType: text("blood_type").notNull().default(""),
|
||||
alerts: jsonb("alerts").$type<string[]>().notNull(),
|
||||
vitalsBp: text("vitals_bp").notNull(),
|
||||
vitalsHr: text("vitals_hr").notNull(),
|
||||
@@ -59,6 +64,10 @@ export const patients = pgTable(
|
||||
// and passes, a scheduled sweep hard-deletes the row (services/wallet-share).
|
||||
shareOrigin: text("share_origin").$type<"wallet">(),
|
||||
shareExpiresAt: timestamp("share_expires_at"),
|
||||
// The patient's wallet number (tmw_…) once they link their wallet from the
|
||||
// Patient Portal. Lets clinic→wallet pushes and portal actions resolve to
|
||||
// this file directly (services/portal.ts, wallet-updates.ts). Nullable.
|
||||
walletNumber: text("wallet_number"),
|
||||
createdBy: text("created_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
||||
privateKeyEnc: text("private_key_enc").notNull(),
|
||||
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
|
||||
// Network" in Settings → Signing). Off by default: only when enabled does the
|
||||
// backend open this clinic's relay hub connection and expose wallet features.
|
||||
// The relay identity *is* this signing key, so the flag lives on the same row.
|
||||
networkEnabled: boolean("network_enabled").notNull().default(false),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
rotatedAt: timestamp("rotated_at"),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,19 @@ const schema = z.object({
|
||||
// Overrides the version reported by GET /api/version. Normally derived from
|
||||
// package.json; the release pipeline can pin it explicitly.
|
||||
APP_VERSION: z.string().optional(),
|
||||
// Temetro Network relay (github.com/temetro/temetro-network). Both this
|
||||
// backend and patient phones connect to it; it routes the encrypted wallet
|
||||
// messages between them. RELAY_URL is the relay's public URL (also baked into
|
||||
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
|
||||
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
|
||||
// now *optional/legacy* — set it only for a private relay that also gates on a
|
||||
// shared token (must then match the relay's RELAY_TOKEN).
|
||||
//
|
||||
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
|
||||
// override only when running your own relay. (A `localhost` default silently
|
||||
// fails inside Docker, where localhost is the container itself.)
|
||||
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
|
||||
RELAY_TOKEN: z.string().default(""),
|
||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||
// QR a patient scans. Optional — when unset we derive it from the request host
|
||||
// (so opening the web app over the LAN yields a reachable LAN URL).
|
||||
@@ -80,6 +93,9 @@ if (env.NODE_ENV === "production") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
|
||||
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
|
||||
// no warning needed.
|
||||
}
|
||||
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { analyticsRouter } from "./routes/analytics.js";
|
||||
import { attachmentsRouter } from "./routes/attachments.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { chatRouter } from "./routes/chat.js";
|
||||
import { clinicRouter } from "./routes/clinic.js";
|
||||
import { conversationsRouter } from "./routes/conversations.js";
|
||||
import { dispensesRouter } from "./routes/dispenses.js";
|
||||
import { fhirRouter } from "./routes/fhir.js";
|
||||
@@ -36,6 +37,7 @@ import { staffRouter } from "./routes/staff.js";
|
||||
import { networkRouter } from "./routes/network.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { versionRouter } from "./routes/version.js";
|
||||
import { initRelayClient } from "./services/relay-client.js";
|
||||
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
||||
import { sweepExpiredShares } from "./services/wallet-share.js";
|
||||
|
||||
@@ -89,6 +91,7 @@ app.use("/api/network", networkRouter);
|
||||
app.use("/api/patients/wallet", patientsWalletRouter);
|
||||
app.use("/api/patients", patientsRouter);
|
||||
app.use("/api/signing", signingRouter);
|
||||
app.use("/api/clinic", clinicRouter);
|
||||
app.use("/api/attachments", attachmentsRouter);
|
||||
app.use("/api/notes", notesRouter);
|
||||
app.use("/api/appointments", appointmentsRouter);
|
||||
@@ -123,6 +126,11 @@ app.use(errorHandler);
|
||||
const server = createServer(app);
|
||||
initRealtime(server);
|
||||
|
||||
// Connect to the Temetro Network relay (the device-facing hub). Patient phones
|
||||
// no longer connect to this backend directly — they connect to the relay, and
|
||||
// we push to / receive from them over its /hub namespace.
|
||||
initRelayClient();
|
||||
|
||||
// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes.
|
||||
const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
@@ -154,7 +162,7 @@ server.listen(env.PORT, () => {
|
||||
console.log(` • portal: /api/portal (public clinic kiosk)`);
|
||||
console.log(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`);
|
||||
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
|
||||
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
|
||||
console.log(` • wallet: /api/patients/wallet (via Temetro Network relay: ${env.RELAY_URL})`);
|
||||
});
|
||||
|
||||
// Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
// is the plaintext key for the *currently selected* provider — it is encrypted
|
||||
// before storage and never echoed back.
|
||||
export const aiConfigInputSchema = z.object({
|
||||
mode: z.enum(["api", "local"]).optional(),
|
||||
mode: z.enum(["api", "local", "auto", "off"]).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
|
||||
ollamaBaseUrl: z.string().url().optional(),
|
||||
ollamaModel: z.string().min(1).max(120).optional(),
|
||||
|
||||
@@ -105,6 +105,10 @@ export const patientInputSchema = z
|
||||
),
|
||||
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
|
||||
initials: z.string().trim().max(4).default(""),
|
||||
phone: z.string().trim().max(30).default(""),
|
||||
bloodType: z
|
||||
.enum(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-", ""])
|
||||
.default(""),
|
||||
allergies: z.array(allergySchema).default([]),
|
||||
alerts: z.array(z.string()).default([]),
|
||||
medications: z.array(medicationSchema).default([]),
|
||||
|
||||
+8
-155
@@ -1,17 +1,14 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { Server, type Socket } from "socket.io";
|
||||
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
|
||||
import * as meetings from "./services/meetings.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import * as walletShare from "./services/wallet-share.js";
|
||||
import * as walletUpdates from "./services/wallet-updates.js";
|
||||
import { sendToWallet } from "./services/relay-client.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
@@ -20,7 +17,6 @@ const userRoom = (userId: string) => `user:${userId}`;
|
||||
const convRoom = (conversationId: string) => `conv:${conversationId}`;
|
||||
const callRoom = (roomId: string) => `call:${roomId}`;
|
||||
const orgRoom = (orgId: string) => `org:${orgId}`;
|
||||
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
|
||||
|
||||
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
|
||||
// past that the room is closed to new joiners.
|
||||
@@ -44,15 +40,18 @@ export function emitToConversation(
|
||||
io?.to(convRoom(conversationId)).emit(event, data);
|
||||
}
|
||||
|
||||
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
|
||||
// namespace, room keyed by wallet number). The relay only ever forwards
|
||||
// ciphertext — it cannot read the record bundle.
|
||||
// Relay an end-to-end-encrypted message to a patient wallet device. Devices no
|
||||
// longer connect to this server directly — they connect to the standalone
|
||||
// Temetro Network relay, which forwards to the room keyed by wallet number. We
|
||||
// push over the relay's /hub namespace (see services/relay-client.ts). The
|
||||
// relay only ever forwards ciphertext — it cannot read the record bundle.
|
||||
export function emitToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
|
||||
sendToWallet(orgId, walletNumber, event, data);
|
||||
}
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
@@ -286,151 +285,5 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Patient wallet relay (/wallet namespace) ----------------------------
|
||||
// Devices have no clinic session, so this namespace is NOT cookie-gated.
|
||||
// Instead a device proves control of its wallet keypair: the server issues a
|
||||
// random challenge, the device signs it with its Ed25519 key, and only then
|
||||
// may it join its own wallet room. The relay forwards encrypted share
|
||||
// requests/responses without ever reading the record bundle.
|
||||
const walletNs = io.of("/wallet");
|
||||
walletNs.on("connection", (socket: Socket) => {
|
||||
const challenge = bytesToHex(randomBytes(32));
|
||||
socket.data.challenge = challenge;
|
||||
socket.data.walletNumber = null as string | null;
|
||||
socket.emit("wallet:challenge", { challenge });
|
||||
|
||||
socket.on(
|
||||
"wallet:auth",
|
||||
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
|
||||
try {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
const signature = String(payload?.signature ?? "");
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
const ok = verifySignature(
|
||||
publicKey,
|
||||
signature,
|
||||
utf8ToBytes(socket.data.challenge as string),
|
||||
);
|
||||
if (!ok) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
socket.data.walletNumber = walletNumber;
|
||||
socket.join(walletRoom(walletNumber));
|
||||
ack?.({ ok: true });
|
||||
// Deliver any record updates the device missed while offline. Sent
|
||||
// after the ack so the client is ready to receive them.
|
||||
void walletUpdates
|
||||
.pendingUpdatesForWallet(walletNumber)
|
||||
.then(async (rows) => {
|
||||
for (const row of rows) {
|
||||
socket.emit("wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a clinic→wallet record update on their device.
|
||||
// We verify the wallet's signature over the decision and resolve the row.
|
||||
socket.on(
|
||||
"wallet:update-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletUpdates.applyUpdateResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share on their device; the sealed bundle (if
|
||||
// approved) rides along and is decrypted + verified server-side.
|
||||
socket.on(
|
||||
"wallet:share-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
sealed?: string;
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletShare.applyShareResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.sealed,
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient revoked a previously shared record; delete it from the clinic.
|
||||
socket.on(
|
||||
"wallet:revoke",
|
||||
async (
|
||||
payload: { requestId?: string; walletNumber?: string },
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const result = await walletShare.revokeShare(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
);
|
||||
ack?.({ ok: !!result });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
}
|
||||
|
||||
const settings = await getAiSettings(req.user!.id);
|
||||
if (settings.mode === "off") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"The AI assistant is turned off. Turn it on in Settings → AI.",
|
||||
);
|
||||
}
|
||||
const modelId = requestedModel || settings.defaultModel;
|
||||
const resolved = resolveModel(settings, modelId);
|
||||
const veil = createVeil(settings.veilLevel, resolved.isExternal);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as clinicSettings from "../services/clinic-settings.js";
|
||||
|
||||
export const clinicRouter = Router();
|
||||
|
||||
clinicRouter.use(requireAuth, requireOrg);
|
||||
|
||||
// The clinic's settings (currently just its location). Readable by any
|
||||
// clinician so the app/UI can display the clinic address.
|
||||
clinicRouter.get(
|
||||
"/settings",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await clinicSettings.getClinicSettings(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Set the clinic's location — owner/admin only (gated on the org-update
|
||||
// statement, same as signing-key rotation / network toggle).
|
||||
const locationSchema = z.object({
|
||||
address: z.string().trim().max(200).default(""),
|
||||
city: z.string().trim().max(120).default(""),
|
||||
country: z.string().trim().max(120).default(""),
|
||||
latitude: z.number().min(-90).max(90).nullable().default(null),
|
||||
longitude: z.number().min(-180).max(180).nullable().default(null),
|
||||
});
|
||||
|
||||
clinicRouter.put(
|
||||
"/location",
|
||||
requirePermission({ organization: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const location = locationSchema.parse(req.body);
|
||||
const view = await clinicSettings.setClinicLocation(
|
||||
req.organizationId!,
|
||||
location,
|
||||
);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: "Updated the clinic location",
|
||||
entityType: "settings",
|
||||
});
|
||||
res.json(view);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
} from "../middleware/auth.js";
|
||||
import { emitToWallet } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, expectResponse } from "../services/relay-client.js";
|
||||
import * as patientService from "../services/patients.js";
|
||||
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
||||
import { getNetworkEnabled } from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
import * as walletUpdates from "../services/wallet-updates.js";
|
||||
|
||||
@@ -26,10 +28,25 @@ export const patientsWalletRouter = Router();
|
||||
|
||||
patientsWalletRouter.use(requireAuth, requireOrg);
|
||||
|
||||
// Wallet sharing rides the Temetro Network relay, which a clinic must opt into
|
||||
// ("Join Temetro Network" in Settings → Signing). Guard the actions that need a
|
||||
// live relay connection so a disabled clinic gets a clear message, not silence.
|
||||
async function requireNetwork(orgId: string): Promise<void> {
|
||||
if (!(await getNetworkEnabled(orgId))) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"This clinic hasn't joined the Temetro Network. Enable it in Settings → Signing to share with patient wallets.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The device-reachable URL the patient's app should connect to (baked into the
|
||||
// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request
|
||||
// host so that opening the web app over the LAN yields a reachable LAN URL.
|
||||
// QR). Devices connect to the standalone Temetro Network relay — the same relay
|
||||
// this backend is hubbed to — so RELAY_URL is the canonical answer. The legacy
|
||||
// PUBLIC_RELAY_URL / cloudflared / request-host fallbacks remain for pre-relay
|
||||
// self-hosting.
|
||||
async function resolveRelayUrl(req: Request): Promise<string> {
|
||||
if (env.RELAY_URL) return env.RELAY_URL;
|
||||
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
||||
// A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to
|
||||
// become reachable so the QR never carries a not-yet-live URL.
|
||||
@@ -68,6 +85,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = pairSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
|
||||
req.organizationId!,
|
||||
@@ -75,6 +93,12 @@ patientsWalletRouter.post(
|
||||
input.mode,
|
||||
input.durationHours,
|
||||
);
|
||||
// No wallet number to `wallet:send` to yet, so pre-register the request id
|
||||
// with the relay so the scanning device's response routes back to us.
|
||||
// Ensure the hub is (re)connected first; if it's still mid-handshake the
|
||||
// on-auth re-registration of pending requests will catch this one.
|
||||
await connectOrg(req.organizationId!);
|
||||
expectResponse(req.organizationId!, view.id);
|
||||
res.status(201).json({
|
||||
...view,
|
||||
ephemeralPubKey,
|
||||
@@ -94,6 +118,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = requestSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
||||
req.organizationId!,
|
||||
@@ -106,7 +131,7 @@ patientsWalletRouter.post(
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, req.organizationId!));
|
||||
emitToWallet(input.walletNumber, "wallet:share-request", {
|
||||
emitToWallet(req.organizationId!, input.walletNumber, "wallet:share-request", {
|
||||
requestId: view.id,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
requestedBy: req.user!.name,
|
||||
@@ -173,6 +198,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = pushSchema.parse(req.body);
|
||||
const row = await walletUpdates.createRecordUpdate(
|
||||
req.organizationId!,
|
||||
@@ -181,7 +207,7 @@ patientsWalletRouter.post(
|
||||
input.changes,
|
||||
);
|
||||
const event = await walletUpdates.toEvent(row);
|
||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
||||
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { Router, type Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { organization } from "../db/schema/auth.js";
|
||||
import { member, organization, user } from "../db/schema/auth.js";
|
||||
import { staffProfile } from "../db/schema/staff-profile.js";
|
||||
import { env } from "../env.js";
|
||||
import { appointmentInputSchema } from "../lib/appointment-validation.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { initialsFromName } from "../lib/initials.js";
|
||||
@@ -11,6 +13,11 @@ import { patientInputSchema } from "../lib/patient-validation.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { createAppointment, listAppointments } from "../services/appointments.js";
|
||||
import { createPatient, getPatient } from "../services/patients.js";
|
||||
import { getOrCreateKey } from "../services/signing.js";
|
||||
|
||||
// Clinical-capable roles that can be a patient's provider (mirrors
|
||||
// staff.ts PROVIDER_ROLES). Department roles (reception, pharmacy, lab) excluded.
|
||||
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
|
||||
|
||||
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
|
||||
// waiting room). Scoped by the clinic slug in the URL — there is no session.
|
||||
@@ -45,12 +52,97 @@ portalRouter.get("/:clinic", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/portal/:clinic/link — the relay-based pairing descriptor the wallet
|
||||
// app scans to talk to this clinic over the Temetro Network: the clinic's
|
||||
// signing public key (the relay's routing id) + the relay URL. Both values are
|
||||
// non-secret (the signing key is the clinic's public identity). This is what
|
||||
// makes the Patient Portal QR reachable from a real phone — it no longer bakes
|
||||
// in a localhost API URL.
|
||||
portalRouter.get("/:clinic/link", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const key = await getOrCreateKey(clinic.id);
|
||||
res.json({
|
||||
clinicId: key.publicKey,
|
||||
relay: env.RELAY_URL,
|
||||
slug: String(req.params.clinic ?? "").trim(),
|
||||
name: clinic.name,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/portal/:clinic/doctors — public list of the clinic's providers so a
|
||||
// patient can pick who to see. Returns only display-safe fields (name +
|
||||
// specialty); no ids, emails, or usernames leave this unauthenticated surface.
|
||||
portalRouter.get("/:clinic/doctors", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const rows = await db
|
||||
.select({ name: user.name, specialty: staffProfile.specialty })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(user.id, member.userId))
|
||||
.leftJoin(
|
||||
staffProfile,
|
||||
and(
|
||||
eq(staffProfile.userId, member.userId),
|
||||
eq(staffProfile.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, clinic.id),
|
||||
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(user.name));
|
||||
res.json(rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null })));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const availabilitySchema = z.object({
|
||||
provider: z.string().trim().max(200).optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
|
||||
});
|
||||
|
||||
// GET /api/portal/:clinic/availability?provider=&date= — the taken time slots
|
||||
// for a provider on a given day, so the kiosk can render only free slots. The
|
||||
// filter mirrors the booking conflict check (an empty-provider appointment
|
||||
// blocks the slot clinic-wide). Booking still re-checks server-side (409).
|
||||
portalRouter.get("/:clinic/availability", async (req, res, next) => {
|
||||
try {
|
||||
const clinic = await resolveClinic(req);
|
||||
const q = availabilitySchema.parse({
|
||||
provider: req.query.provider,
|
||||
date: req.query.date,
|
||||
});
|
||||
const provider = q.provider ?? "";
|
||||
const taken = (await listAppointments(clinic.id))
|
||||
.filter(
|
||||
(a) =>
|
||||
a.status !== "cancelled" &&
|
||||
a.date === q.date &&
|
||||
(!provider || !a.provider || a.provider === provider),
|
||||
)
|
||||
.map((a) => a.time);
|
||||
res.json({ date: q.date, provider, taken: [...new Set(taken)].sort() });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const bookingSchema = z.object({
|
||||
fileNumber: z.string().trim().min(1, "A file number is required.").max(64),
|
||||
name: z.string().trim().min(1, "Your name is required.").max(200),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
|
||||
type: z.string().trim().max(120).optional(),
|
||||
// Chosen provider (doctor name) from the portal's doctor picker; falls back
|
||||
// to the patient's PCP when omitted.
|
||||
provider: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
const newPatientSchema = z.object({
|
||||
@@ -114,7 +206,7 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
|
||||
date: body.date,
|
||||
time: body.time,
|
||||
type: body.type || "Self-service booking",
|
||||
provider: patient.pcp || "",
|
||||
provider: body.provider || patient.pcp || "",
|
||||
status: "confirmed",
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
|
||||
import * as signing from "../services/signing.js";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
|
||||
@@ -48,6 +50,52 @@ signingRouter.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Readable by any
|
||||
// clinician (the panel shows the toggle state + connection status).
|
||||
signingRouter.get(
|
||||
"/network",
|
||||
requirePermission({ patient: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Join / leave the Temetro Network — owner/admin only (same gate as key
|
||||
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
|
||||
// it down.
|
||||
const networkSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
signingRouter.put(
|
||||
"/network",
|
||||
requirePermission({ organization: ["update"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { enabled } = networkSchema.parse(req.body);
|
||||
await signing.setNetworkEnabled(req.organizationId!, enabled);
|
||||
if (enabled) {
|
||||
await connectOrg(req.organizationId!);
|
||||
} else {
|
||||
disconnectOrg(req.organizationId!);
|
||||
}
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: enabled
|
||||
? "Joined the Temetro Network"
|
||||
: "Left the Temetro Network",
|
||||
entityType: "settings",
|
||||
});
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recent records shared from patient wallets — feeds the panel's shared-records
|
||||
// list.
|
||||
signingRouter.get(
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
type AiSettingsRow = typeof userAiSettings.$inferSelect;
|
||||
|
||||
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
|
||||
mode: "local",
|
||||
// Default to auto: use a cloud key if the user adds one, else local Ollama.
|
||||
// A fresh user with nothing configured then sees the setup banner.
|
||||
mode: "auto",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
|
||||
ollamaModel: "llama3.1",
|
||||
|
||||
@@ -69,8 +69,30 @@ export function resolveModel(
|
||||
settings: AiSettingsRow,
|
||||
requestedModelId: string,
|
||||
): ResolvedModel {
|
||||
// Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint.
|
||||
if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) {
|
||||
// Off → the assistant is disabled. Guard here in case a request slips past the
|
||||
// route-level short-circuit.
|
||||
if (settings.mode === "off") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"The AI assistant is turned off. Turn it on in Settings → AI.",
|
||||
);
|
||||
}
|
||||
|
||||
const requested = providerForModel(requestedModelId);
|
||||
// In api/auto mode, find a cloud provider that actually has a key. Auto with
|
||||
// no key (and api mode's remaining fall-through) drops to local Ollama below.
|
||||
const provider =
|
||||
settings.mode === "api" || settings.mode === "auto"
|
||||
? chooseProvider(settings, requested)
|
||||
: null;
|
||||
|
||||
// Local when: explicit local mode, the local sentinel was picked, or auto with
|
||||
// no configured cloud key. → Ollama's OpenAI-compatible endpoint.
|
||||
if (
|
||||
settings.mode === "local" ||
|
||||
requestedModelId === OLLAMA_SENTINEL ||
|
||||
(settings.mode === "auto" && !provider)
|
||||
) {
|
||||
const ollama = createOpenAICompatible({
|
||||
name: "ollama",
|
||||
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
|
||||
@@ -82,11 +104,7 @@ export function resolveModel(
|
||||
};
|
||||
}
|
||||
|
||||
// API mode. Pick a provider that actually has a key, falling back gracefully
|
||||
// so a configured key (e.g. Gemini) is used even if the picked model belongs
|
||||
// to a different, unconfigured provider.
|
||||
const requested = providerForModel(requestedModelId);
|
||||
const provider = chooseProvider(settings, requested);
|
||||
// API mode with a picked cloud model but no matching/any key.
|
||||
if (!provider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { clinicSettings } from "../db/schema/clinic-settings.js";
|
||||
|
||||
export type ClinicLocation = {
|
||||
address: string;
|
||||
city: string;
|
||||
country: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
|
||||
export type ClinicSettingsView = {
|
||||
location: ClinicLocation;
|
||||
};
|
||||
|
||||
const EMPTY_LOCATION: ClinicLocation = {
|
||||
address: "",
|
||||
city: "",
|
||||
country: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
};
|
||||
|
||||
type ClinicSettingsRow = typeof clinicSettings.$inferSelect;
|
||||
|
||||
function toView(row: ClinicSettingsRow | undefined): ClinicSettingsView {
|
||||
if (!row) return { location: { ...EMPTY_LOCATION } };
|
||||
return {
|
||||
location: {
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
country: row.country,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Read a clinic's settings. Returns empty defaults when no row exists yet, so
|
||||
// the panel always renders.
|
||||
export async function getClinicSettings(
|
||||
orgId: string,
|
||||
): Promise<ClinicSettingsView> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clinicSettings)
|
||||
.where(eq(clinicSettings.organizationId, orgId))
|
||||
.limit(1);
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
// Upsert the clinic's location (address + optional coordinates).
|
||||
export async function setClinicLocation(
|
||||
orgId: string,
|
||||
location: ClinicLocation,
|
||||
): Promise<ClinicSettingsView> {
|
||||
const values = {
|
||||
organizationId: orgId,
|
||||
address: location.address,
|
||||
city: location.city,
|
||||
country: location.country,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(clinicSettings)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: clinicSettings.organizationId,
|
||||
set: {
|
||||
address: values.address,
|
||||
city: values.city,
|
||||
country: values.country,
|
||||
latitude: values.latitude,
|
||||
longitude: values.longitude,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toView(row);
|
||||
}
|
||||
@@ -53,6 +53,8 @@ function toPatient(row: PatientRow, children: Children): Patient {
|
||||
primaryProviderId: row.primaryProviderId,
|
||||
status: row.status,
|
||||
initials: row.initials,
|
||||
phone: row.phone,
|
||||
bloodType: row.bloodType,
|
||||
allergies: children.allergies,
|
||||
alerts: row.alerts,
|
||||
medications: children.medications,
|
||||
@@ -82,6 +84,8 @@ const EMPTY_TREND: Trend = { label: "", unit: "", points: [] };
|
||||
function redactClinical(patient: Patient): Patient {
|
||||
return {
|
||||
...patient,
|
||||
// bloodType is clinical PHI; phone is a demographic/contact field and stays.
|
||||
bloodType: "",
|
||||
allergies: [],
|
||||
alerts: [],
|
||||
medications: [],
|
||||
@@ -116,6 +120,8 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
|
||||
primaryProviderId: input.primaryProviderId ?? null,
|
||||
status: input.status,
|
||||
initials: input.initials,
|
||||
phone: input.phone,
|
||||
bloodType: input.bloodType,
|
||||
alerts: input.alerts,
|
||||
vitalsBp: input.vitals.bp,
|
||||
vitalsHr: input.vitals.hr,
|
||||
@@ -147,6 +153,8 @@ function demographicColumns(
|
||||
primaryProviderId: input.primaryProviderId ?? null,
|
||||
status: input.status,
|
||||
initials: input.initials,
|
||||
phone: input.phone,
|
||||
bloodType: "",
|
||||
source: input.source,
|
||||
alerts: [] as string[],
|
||||
vitalsBp: "",
|
||||
@@ -172,6 +180,7 @@ function demographicUpdateColumns(input: PatientInput) {
|
||||
primaryProviderId: input.primaryProviderId ?? null,
|
||||
status: input.status,
|
||||
initials: input.initials,
|
||||
phone: input.phone,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Patient Portal actions, shared by the public REST kiosk (routes/portal.ts)
|
||||
// and the relay path used by the wallet app (relay-client.ts handles
|
||||
// `portal:request` and dispatches here). Both go through the same clinic-scoped
|
||||
// logic so a booking made in the phone shows up on the clinic's Appointments
|
||||
// page exactly like a kiosk booking.
|
||||
//
|
||||
// The relay path identifies the patient by their *verified* wallet number (the
|
||||
// relay only forwards a request after the device signed the relay challenge),
|
||||
// which is more trustworthy than the kiosk's name + file-number check.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { member, organization, user } from "../db/schema/auth.js";
|
||||
import { patients } from "../db/schema/patients.js";
|
||||
import { staffProfile } from "../db/schema/staff-profile.js";
|
||||
import { appointmentInputSchema } from "../lib/appointment-validation.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { initialsFromName } from "../lib/initials.js";
|
||||
import { recordActivity } from "./activity.js";
|
||||
import { createAppointment, listAppointments } from "./appointments.js";
|
||||
import {
|
||||
absolutePath,
|
||||
getAttachmentRow,
|
||||
listAttachments,
|
||||
} from "./attachments.js";
|
||||
import { getPatient } from "./patients.js";
|
||||
|
||||
// Clinical-capable roles that can be a patient's provider (mirrors portal.ts).
|
||||
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
|
||||
|
||||
export type PortalDoctor = { name: string; specialty: string | null };
|
||||
|
||||
export async function getClinicInfo(orgId: string): Promise<{ name: string }> {
|
||||
const [org] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, orgId))
|
||||
.limit(1);
|
||||
if (!org) throw new HttpError(404, "Clinic not found.");
|
||||
return { name: org.name };
|
||||
}
|
||||
|
||||
export async function listDoctors(orgId: string): Promise<PortalDoctor[]> {
|
||||
const rows = await db
|
||||
.select({ name: user.name, specialty: staffProfile.specialty })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(user.id, member.userId))
|
||||
.leftJoin(
|
||||
staffProfile,
|
||||
and(
|
||||
eq(staffProfile.userId, member.userId),
|
||||
eq(staffProfile.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgId),
|
||||
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(user.name));
|
||||
return rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null }));
|
||||
}
|
||||
|
||||
// Taken time slots for a provider on a day, so the client renders only free
|
||||
// ones. Mirrors routes/portal.ts (an empty-provider appointment blocks the slot
|
||||
// clinic-wide). Booking re-checks server-side.
|
||||
export async function getAvailability(
|
||||
orgId: string,
|
||||
provider: string,
|
||||
date: string,
|
||||
): Promise<{ date: string; provider: string; taken: string[] }> {
|
||||
const taken = (await listAppointments(orgId))
|
||||
.filter(
|
||||
(a) =>
|
||||
a.status !== "cancelled" &&
|
||||
a.date === date &&
|
||||
(!provider || !a.provider || a.provider === provider),
|
||||
)
|
||||
.map((a) => a.time);
|
||||
return { date, provider, taken: [...new Set(taken)].sort() };
|
||||
}
|
||||
|
||||
// --- wallet linkage ---------------------------------------------------------
|
||||
|
||||
// Confirm the wallet link for a device. The wallet is identified purely by its
|
||||
// relay-verified wallet number — the clinic attaches that number to the patient
|
||||
// file ahead of time (via "Import from a patient app" / QR pairing, which sets
|
||||
// `patients.walletNumber`), so the device never types a name or file number.
|
||||
// Resolves the linked file, or a friendly 404 when the clinic hasn't paired yet.
|
||||
export async function linkWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
): Promise<{ fileNumber: string; name: string }> {
|
||||
if (!walletNumber) {
|
||||
throw new HttpError(400, "Missing wallet identity.");
|
||||
}
|
||||
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
|
||||
if (!fileNumber) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"This wallet isn't paired with a record at this clinic yet. Ask the front desk to add your wallet number, then try again.",
|
||||
);
|
||||
}
|
||||
const patient = await getPatient(orgId, fileNumber);
|
||||
if (!patient) throw new HttpError(404, "Linked record not found.");
|
||||
await recordActivity({
|
||||
orgId,
|
||||
actor: { id: "", name: patient.name },
|
||||
action: `Patient portal — ${patient.name} confirmed their wallet link`,
|
||||
entityType: "patient",
|
||||
entityId: patient.fileNumber,
|
||||
});
|
||||
return { fileNumber: patient.fileNumber, name: patient.name };
|
||||
}
|
||||
|
||||
// The file number a linked wallet maps to, or null.
|
||||
export async function fileNumberForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ fileNumber: patients.fileNumber })
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
eq(patients.walletNumber, walletNumber),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.fileNumber ?? null;
|
||||
}
|
||||
|
||||
async function requireLinkedPatient(orgId: string, walletNumber: string) {
|
||||
const fileNumber = await fileNumberForWallet(orgId, walletNumber);
|
||||
if (!fileNumber) {
|
||||
throw new HttpError(403, "This wallet isn't linked to a record at this clinic.");
|
||||
}
|
||||
const patient = await getPatient(orgId, fileNumber);
|
||||
if (!patient) throw new HttpError(404, "Linked record not found.");
|
||||
return patient;
|
||||
}
|
||||
|
||||
// Book an appointment for the linked wallet (conflict-checked), attributed to
|
||||
// the patient's file so it appears on the clinic's Appointments page.
|
||||
export async function bookForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
body: { date: string; time: string; type?: string; provider?: string },
|
||||
): Promise<{ date: string; time: string; type: string; provider: string }> {
|
||||
const patient = await requireLinkedPatient(orgId, walletNumber);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (body.date < today) throw new HttpError(400, "Please pick a future date.");
|
||||
|
||||
const input = appointmentInputSchema.parse({
|
||||
fileNumber: patient.fileNumber,
|
||||
name: patient.name,
|
||||
initials: patient.initials || initialsFromName(patient.name),
|
||||
date: body.date,
|
||||
time: body.time,
|
||||
type: body.type || "Self-service booking",
|
||||
provider: body.provider || patient.pcp || "",
|
||||
status: "confirmed",
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
const taken = (await listAppointments(orgId)).some(
|
||||
(a) =>
|
||||
a.status !== "cancelled" &&
|
||||
a.date === input.date &&
|
||||
a.time === input.time &&
|
||||
(!input.provider || !a.provider || a.provider === input.provider),
|
||||
);
|
||||
if (taken) {
|
||||
throw new HttpError(409, "That time slot is already taken. Please choose another time.");
|
||||
}
|
||||
|
||||
const created = await createAppointment(orgId, "", input);
|
||||
await recordActivity({
|
||||
orgId,
|
||||
actor: { id: "", name: patient.name },
|
||||
action: `Patient portal booking — ${patient.name} on ${created.date} ${created.time}`,
|
||||
entityType: "appointment",
|
||||
entityId: created.id,
|
||||
});
|
||||
return {
|
||||
date: created.date,
|
||||
time: created.time,
|
||||
type: created.type,
|
||||
provider: created.provider,
|
||||
};
|
||||
}
|
||||
|
||||
// Results view for the linked wallet: upcoming appointments + downloadable lab
|
||||
// files (metadata only; bytes come from `getResultFile`).
|
||||
export async function resultsForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
upcoming: { date: string; time: string; type: string; provider: string; status: string }[];
|
||||
files: { id: string; filename: string; mimeType: string; sizeBytes: number; labKey: string | null }[];
|
||||
}> {
|
||||
const patient = await requireLinkedPatient(orgId, walletNumber);
|
||||
const now = new Date();
|
||||
const upcoming = (await listAppointments(orgId))
|
||||
.filter(
|
||||
(a) =>
|
||||
a.fileNumber === patient.fileNumber &&
|
||||
a.status !== "cancelled" &&
|
||||
new Date(`${a.date}T${a.time}`) >= now,
|
||||
)
|
||||
.map((a) => ({
|
||||
date: a.date,
|
||||
time: a.time,
|
||||
type: a.type,
|
||||
provider: a.provider,
|
||||
status: a.status,
|
||||
}));
|
||||
const files = (await listAttachments(orgId, patient.fileNumber)).map((f) => ({
|
||||
id: f.id,
|
||||
filename: f.filename,
|
||||
mimeType: f.mimeType,
|
||||
sizeBytes: f.sizeBytes,
|
||||
labKey: f.labKey,
|
||||
}));
|
||||
return { name: patient.name, upcoming, files };
|
||||
}
|
||||
|
||||
// A single lab/result file for the linked wallet, base64-encoded so it can ride
|
||||
// back over the relay. Verifies the file belongs to the patient's own record.
|
||||
export async function resultFileForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
attachmentId: string,
|
||||
): Promise<{ filename: string; mimeType: string; base64: string }> {
|
||||
const patient = await requireLinkedPatient(orgId, walletNumber);
|
||||
const row = await getAttachmentRow(orgId, attachmentId);
|
||||
if (!row || row.fileNumber !== patient.fileNumber) {
|
||||
throw new HttpError(404, "File not found.");
|
||||
}
|
||||
const bytes = await readFile(absolutePath(row.storagePath));
|
||||
return {
|
||||
filename: row.filename,
|
||||
mimeType: row.mimeType,
|
||||
base64: bytes.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- relay dispatch ---------------------------------------------------------
|
||||
|
||||
type PortalPayload = Record<string, unknown>;
|
||||
|
||||
// Dispatch a `portal:request` relayed from a wallet device. `walletNumber` is
|
||||
// the device's relay-verified wallet number (empty for the public reads).
|
||||
export async function handlePortalRequest(
|
||||
orgId: string,
|
||||
req: { action: string; payload: PortalPayload; walletNumber: string },
|
||||
): Promise<unknown> {
|
||||
const { action, payload, walletNumber } = req;
|
||||
const s = (k: string): string => String(payload[k] ?? "");
|
||||
switch (action) {
|
||||
case "clinic":
|
||||
return getClinicInfo(orgId);
|
||||
case "doctors":
|
||||
return listDoctors(orgId);
|
||||
case "availability":
|
||||
return getAvailability(orgId, s("provider"), s("date"));
|
||||
case "link":
|
||||
return linkWallet(orgId, walletNumber);
|
||||
case "book":
|
||||
return bookForWallet(orgId, walletNumber, {
|
||||
date: s("date"),
|
||||
time: s("time"),
|
||||
type: s("type") || undefined,
|
||||
provider: s("provider") || undefined,
|
||||
});
|
||||
case "results":
|
||||
return resultsForWallet(orgId, walletNumber);
|
||||
case "result-file":
|
||||
return resultFileForWallet(orgId, walletNumber, s("id"));
|
||||
default:
|
||||
throw new HttpError(400, `Unknown portal action: ${action}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Client connection to the Temetro Network relay
|
||||
// (github.com/temetro/temetro-network), a standalone Rust service that routes
|
||||
// encrypted wallet messages between this backend and patient phones.
|
||||
//
|
||||
// This backend was previously the device-facing Socket.io server itself (the
|
||||
// `/wallet` namespace in realtime.ts). Now it is a *client* of the relay's
|
||||
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
|
||||
// their responses here, calling the same wallet service functions the old socket
|
||||
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
|
||||
// the relay only ever forwards ciphertext.
|
||||
//
|
||||
// The relay is **multi-clinic**: each clinic (organization) authenticates to
|
||||
// `/hub` with its own Ed25519 signing key (a per-clinic identity, not a shared
|
||||
// password), and the relay routes each device response back only to the clinic
|
||||
// that originated the request. So this backend keeps **one hub connection per
|
||||
// network-enabled org**, opened when the org joins the network ("Join Temetro
|
||||
// Network" in Settings → Signing) and torn down when it leaves.
|
||||
|
||||
import { io as connect, type Socket } from "socket.io-client";
|
||||
|
||||
import { env } from "../env.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { handlePortalRequest } from "./portal.js";
|
||||
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
|
||||
import * as walletShare from "./wallet-share.js";
|
||||
import * as walletUpdates from "./wallet-updates.js";
|
||||
|
||||
// One authenticated hub connection per network-enabled organization.
|
||||
const hubs = new Map<string, Socket>();
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
// Push an end-to-end-encrypted message to a patient wallet device via the given
|
||||
// clinic's relay connection (the relay forwards it to the room keyed by wallet
|
||||
// number). A no-op if the clinic isn't on the network / not connected yet — the
|
||||
// device replays anything it missed on its next connect (see `wallet:online`).
|
||||
export function sendToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||
}
|
||||
|
||||
// Tell the relay to expect a device response for `requestId` and route it back
|
||||
// to this clinic — used by **QR pairing**, where there's no wallet number to
|
||||
// `wallet:send` to yet, so nothing would otherwise register the request.
|
||||
export function expectResponse(orgId: string, requestId: string): void {
|
||||
hubs.get(orgId)?.emit("hub:expect", { requestId });
|
||||
}
|
||||
|
||||
// Open (and authenticate) a hub connection for a clinic, if not already open.
|
||||
// Idempotent — safe to call on startup, when an org joins the network, and
|
||||
// before generating a pairing QR. The socket auto-reconnects on its own, so an
|
||||
// existing entry is left as-is.
|
||||
export async function connectOrg(orgId: string): Promise<void> {
|
||||
if (hubs.has(orgId)) return;
|
||||
|
||||
const hub = connect(`${env.RELAY_URL}/hub`, {
|
||||
transports: ["websocket"],
|
||||
reconnection: true,
|
||||
reconnectionDelayMax: 10_000,
|
||||
});
|
||||
hubs.set(orgId, hub);
|
||||
registerHubHandlers(orgId, hub);
|
||||
}
|
||||
|
||||
// Leave the network for a clinic: close and forget its hub connection.
|
||||
export function disconnectOrg(orgId: string): void {
|
||||
const hub = hubs.get(orgId);
|
||||
if (!hub) return;
|
||||
hub.disconnect();
|
||||
hubs.delete(orgId);
|
||||
}
|
||||
|
||||
// Open a hub connection for every clinic already on the network. Called once at
|
||||
// startup; runtime joins/leaves go through connectOrg/disconnectOrg.
|
||||
export async function initRelayClient(): Promise<void> {
|
||||
try {
|
||||
const orgs = await networkEnabledOrgs();
|
||||
await Promise.all(orgs.map((orgId) => connectOrg(orgId)));
|
||||
} catch (err) {
|
||||
console.warn(`Temetro Network: failed to open hub connections: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up auth + device-response handlers for one clinic's hub socket.
|
||||
function registerHubHandlers(orgId: string, hub: Socket): void {
|
||||
// Authenticate by signing the relay's challenge with this clinic's signing
|
||||
// key. `clinicId` is that key's public half (hex), which is how the relay
|
||||
// identifies and routes to this clinic.
|
||||
hub.on("hub:challenge", async (payload: { challenge?: string }) => {
|
||||
const challenge = String(payload?.challenge ?? "");
|
||||
if (!challenge) return;
|
||||
try {
|
||||
const { signature, publicKey } = await signWithClinicKey(
|
||||
orgId,
|
||||
new TextEncoder().encode(challenge),
|
||||
);
|
||||
hub.emit(
|
||||
"hub:auth",
|
||||
// `token` is only meaningful for a private relay (optional shared gate);
|
||||
// an empty value is ignored by an open relay.
|
||||
{ clinicId: publicKey, signature, token: env.RELAY_TOKEN || undefined },
|
||||
async (ack: { ok?: boolean } | undefined) => {
|
||||
if (!ack?.ok) {
|
||||
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
|
||||
// The relay keeps routing state in memory, so re-register this clinic's
|
||||
// still-pending requests — restores QR-pairing / share routing after a
|
||||
// relay restart or a reconnect.
|
||||
try {
|
||||
for (const requestId of await walletShare.pendingRequestIds(orgId)) {
|
||||
expectResponse(orgId, requestId);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
hub.on("connect_error", (err) => {
|
||||
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}) for ${orgId}: ${err.message}`);
|
||||
});
|
||||
|
||||
// A device authenticated on the relay — flush any record updates it missed
|
||||
// while offline (scoped to this clinic; the relay only delivers this to
|
||||
// clinics with pending work for the wallet).
|
||||
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
if (!walletNumber) return;
|
||||
try {
|
||||
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
|
||||
for (const row of rows) {
|
||||
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
// The patient approved/denied a clinic→wallet record update. Verify the
|
||||
// wallet's signature over the decision and resolve the row.
|
||||
hub.on(
|
||||
"wallet:update-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const view = await walletUpdates.applyUpdateResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share; the sealed bundle (if approved) rides
|
||||
// along and is decrypted + verified here.
|
||||
hub.on(
|
||||
"wallet:share-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
sealed?: string;
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const view = await walletShare.applyShareResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.sealed,
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// A wallet app made a Patient Portal request over the relay (book, view
|
||||
// results, link, …). The relay forwards it here with the device's verified
|
||||
// wallet number; we run the same portal logic the web kiosk uses and ack the
|
||||
// result back down the relay to the device.
|
||||
hub.on(
|
||||
"portal:request",
|
||||
async (
|
||||
payload: {
|
||||
action?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
walletNumber?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const data = await handlePortalRequest(orgId, {
|
||||
action: String(payload?.action ?? ""),
|
||||
payload: payload?.payload ?? {},
|
||||
walletNumber: String(payload?.walletNumber ?? ""),
|
||||
});
|
||||
ack?.({ ok: true, data });
|
||||
} catch (err) {
|
||||
const status = err instanceof HttpError ? err.status : 500;
|
||||
ack?.({ ok: false, error: (err as Error).message, status });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient revoked a previously shared record; delete it from the clinic.
|
||||
hub.on(
|
||||
"wallet:revoke",
|
||||
async (payload: { requestId?: string; walletNumber?: string }, ack?: Ack) => {
|
||||
try {
|
||||
const result = await walletShare.revokeShare(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
);
|
||||
ack?.({ ok: !!result });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
return mintKey(orgId, true);
|
||||
}
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
|
||||
// when the clinic has no signing key yet (it hasn't opted in).
|
||||
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return row?.networkEnabled ?? false;
|
||||
}
|
||||
|
||||
// Org ids of every clinic currently on the network — used at startup to open a
|
||||
// relay hub connection for each.
|
||||
export async function networkEnabledOrgs(): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ organizationId: clinicSigningKeys.organizationId })
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.networkEnabled, true));
|
||||
return rows.map((r) => r.organizationId);
|
||||
}
|
||||
|
||||
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
|
||||
// (the relay authenticates with it), then flips the flag. Returns the new state.
|
||||
export async function setNetworkEnabled(
|
||||
orgId: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
await getOrCreateKey(orgId);
|
||||
await db
|
||||
.update(clinicSigningKeys)
|
||||
.set({ networkEnabled: enabled })
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
|
||||
@@ -137,6 +137,22 @@ export async function listShareRequests(
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Ids of this clinic's still-pending share/pairing requests. Used to re-register
|
||||
// them with the relay when the clinic's hub (re)connects (the relay keeps
|
||||
// routing state in memory, so it's lost on a relay restart / redeploy).
|
||||
export async function pendingRequestIds(orgId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ id: walletShareRequests.id })
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
eq(walletShareRequests.status, "pending"),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// Apply a response relayed back from the patient's device. On approval we
|
||||
// decrypt the sealed bundle with the request's ephemeral private key and verify
|
||||
// the wallet's Ed25519 signature over it (provenance: it really came from that
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { organization } from "../db/schema/auth.js";
|
||||
import { patients } from "../db/schema/patients.js";
|
||||
import { walletRecordUpdates } from "../db/schema/wallet-updates.js";
|
||||
import { walletShareRequests } from "../db/schema/wallet-share.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
verifySignature,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
|
||||
import { listAppointments } from "./appointments.js";
|
||||
import { listAttachments } from "./attachments.js";
|
||||
import { listInvoices } from "./invoices.js";
|
||||
import { getPatient } from "./patients.js";
|
||||
import { signWithClinicKey } from "./signing.js";
|
||||
|
||||
@@ -68,6 +72,22 @@ export async function walletNumberForPatient(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
): Promise<string | null> {
|
||||
// Preferred: the wallet number the patient linked from the Patient Portal
|
||||
// (stored directly on the file). Falls back to a permanent, approved,
|
||||
// committed share for records imported the older way.
|
||||
const [linked] = await db
|
||||
.select({ walletNumber: patients.walletNumber })
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
eq(patients.organizationId, orgId),
|
||||
eq(patients.fileNumber, fileNumber),
|
||||
isNotNull(patients.walletNumber),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (linked?.walletNumber) return linked.walletNumber;
|
||||
|
||||
const [row] = await db
|
||||
.select({ walletNumber: walletShareRequests.walletNumber })
|
||||
.from(walletShareRequests)
|
||||
@@ -100,9 +120,33 @@ export async function createRecordUpdate(
|
||||
const patient = await getPatient(orgId, fileNumber);
|
||||
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||
|
||||
// Appointments and invoices live in their own tables (not on the Patient
|
||||
// snapshot), so pull the ones for this patient and ship them alongside — the
|
||||
// wallet has no other way to see them and they'd otherwise silently vanish.
|
||||
// Attachments (files/documents) are shipped as metadata so the wallet can list
|
||||
// them and show a count; the bytes stay on the clinic for now.
|
||||
const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([
|
||||
listAppointments(orgId),
|
||||
listInvoices(orgId),
|
||||
listAttachments(orgId, fileNumber),
|
||||
]);
|
||||
const appointments = orgAppointments.filter(
|
||||
(a) => a.fileNumber === fileNumber,
|
||||
);
|
||||
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
|
||||
const documents = attachmentRows.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
sizeBytes: a.sizeBytes,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
// The wallet opens this, verifies the signature over the same bytes, then
|
||||
// replaces its on-device record with `patient`.
|
||||
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
|
||||
// replaces its on-device record with `patient` (+ appointments/invoices/documents).
|
||||
const bundle = utf8ToBytes(
|
||||
JSON.stringify({ patient, appointments, invoices, documents, changes }),
|
||||
);
|
||||
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
|
||||
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
|
||||
const sealed = seal(x25519Hex, bundle);
|
||||
@@ -144,7 +188,11 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
||||
|
||||
// Every unresolved update for a wallet — re-sent on each authenticated connect
|
||||
// so an offline device eventually receives what it missed.
|
||||
// Pending updates a wallet missed, scoped to one clinic — the relay delivers a
|
||||
// `wallet:online` over that clinic's own hub connection, so a clinic only ever
|
||||
// re-sends its *own* updates (never another clinic's).
|
||||
export async function pendingUpdatesForWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
): Promise<UpdateRow[]> {
|
||||
return db
|
||||
@@ -152,6 +200,7 @@ export async function pendingUpdatesForWallet(
|
||||
.from(walletRecordUpdates)
|
||||
.where(
|
||||
and(
|
||||
eq(walletRecordUpdates.organizationId, orgId),
|
||||
eq(walletRecordUpdates.walletNumber, walletNumber),
|
||||
isNull(walletRecordUpdates.resolvedAt),
|
||||
),
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// AI panel. The chat agent reads these to decide which provider/model to call
|
||||
// and how strict the Veil de-identification safeguard should be.
|
||||
|
||||
// Two inference modes: a user-provided cloud API key, or a local Ollama model.
|
||||
export type AiMode = "api" | "local";
|
||||
// Inference modes:
|
||||
// api — a user-provided cloud API key
|
||||
// local — a local Ollama model
|
||||
// auto — auto-pick: use a cloud key when one is set, else fall back to local
|
||||
// off — the assistant is disabled
|
||||
export type AiMode = "api" | "local" | "auto" | "off";
|
||||
|
||||
// The three supported cloud providers for API-key mode.
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
|
||||
@@ -61,6 +61,8 @@ export type Patient = {
|
||||
primaryProviderId?: string | null; // user id of the responsible clinician
|
||||
status: PatientStatus;
|
||||
initials: string; // for AvatarFallback
|
||||
phone?: string; // contact number (demographic; visible to reception)
|
||||
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
|
||||
allergies: Allergy[];
|
||||
alerts: string[];
|
||||
medications: Medication[];
|
||||
|
||||
+16
-8
@@ -12,8 +12,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
`temetro` — an **open-source** clinical "AI middleman" (see the project vision in the root
|
||||
`../CLAUDE.md`). This app is the clinician-facing chat UI. It is now **wired to the real
|
||||
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo); the AI
|
||||
chat replies are **still mocked** (no LLM call yet).
|
||||
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo). The AI
|
||||
chat is **real**: `components/chat/chat-panel.tsx` streams from the backend's tool-using agent
|
||||
(`POST /api/chat`) via `useChat` and renders the record data parts it streams back.
|
||||
|
||||
- The chat parses `/patient <file#>` (or a bare `/<file#>`) in `components/chat/chat-panel.tsx` and
|
||||
renders the record as a horizontal row of cards (`components/chat/patient-cards.tsx`), with small
|
||||
@@ -21,7 +22,7 @@ chat replies are **still mocked** (no LLM call yet).
|
||||
dialog; the Summary card has an **Edit record** button.
|
||||
- Patients can be **created and edited** via the shared `components/chat/patient-form-dialog.tsx`
|
||||
(mode `create` | `edit`). The "Add patient" pill in `chat-input.tsx` opens it.
|
||||
- Non-command messages still get a mock assistant reply.
|
||||
- Non-command messages stream a real reply from the backend agent (`POST /api/chat`).
|
||||
|
||||
### Auth & data (talks to `../backend`)
|
||||
|
||||
@@ -42,7 +43,8 @@ chat replies are **still mocked** (no LLM call yet).
|
||||
`components/settings/settings-care-team.tsx` manages members + invitations.
|
||||
- `.env.local` / `.env.example` hold `NEXT_PUBLIC_API_URL`.
|
||||
|
||||
The signing / patient-owned-storage / approval features from the root vision are **still not built**.
|
||||
The signing / patient-owned-storage / approval features from the root vision **are built** (clinic
|
||||
signing key, encrypted share/import, patient approval, clinic→wallet update push, QR pairing).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -77,8 +79,8 @@ trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit poli
|
||||
`components.json` (baseColor `neutral`).
|
||||
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
|
||||
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
|
||||
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
|
||||
is managed with local React state.
|
||||
`ChatStatus`, `FileUIPart`). `@ai-sdk/react` (`useChat`) **is installed** and drives the live chat
|
||||
(`chat-panel.tsx`) with a custom transport pointed at `POST /api/chat`.
|
||||
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
|
||||
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat
|
||||
· Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch).
|
||||
@@ -104,12 +106,18 @@ white/6%), so layered surfaces stay close in lightness.
|
||||
|
||||
## i18n
|
||||
|
||||
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in
|
||||
`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in
|
||||
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, resources in
|
||||
`lib/i18n/locales/<lng>/translation.json`). `components/i18n-provider.tsx` wraps the app in
|
||||
`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`)
|
||||
in **client** components. To add a language, drop a `locales/<lng>/translation.json` and register it
|
||||
in `resources`/`supportedLngs` in `config.ts`.
|
||||
|
||||
> **Translate into EVERY locale, not just English.** The app ships multiple languages
|
||||
> (`lib/i18n/locales/`: currently `en`, `de`, `fr`, `ar`, `so`). Whenever you add or rename a
|
||||
> translation key, add it to **all** `locales/*/translation.json` files with a real translation for
|
||||
> each language (not the English string copied over) — leaving a key in only `en/` ships a broken UI
|
||||
> in the others. Keep the nested structure identical across every locale file.
|
||||
|
||||
**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets,
|
||||
auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages,
|
||||
notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`,
|
||||
|
||||
@@ -369,15 +369,18 @@ export const AttachmentRemove = ({
|
||||
// AttachmentHoverCard - Hover preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard> & {
|
||||
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
|
||||
// back-compat but no longer forwarded to the Root.
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const AttachmentHoverCard = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
openDelay,
|
||||
closeDelay,
|
||||
...props
|
||||
}: AttachmentHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
}: AttachmentHoverCardProps) => <HoverCard {...props} />;
|
||||
|
||||
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Context = ({
|
||||
|
||||
return (
|
||||
<ContextContext.Provider value={contextValue}>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
<HoverCard {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export const InlineCitationText = ({
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
<HoverCard {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
|
||||
@@ -128,5 +128,18 @@ export const PlanFooter = (props: PlanFooterProps) => (
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
|
||||
<CollapsibleTrigger
|
||||
{...props}
|
||||
render={
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
@@ -1311,15 +1311,18 @@ export const PromptInputSelectValue = ({
|
||||
<SelectValue className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard> & {
|
||||
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
|
||||
// back-compat but no longer forwarded to the Root.
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const PromptInputHoverCard = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
openDelay,
|
||||
closeDelay,
|
||||
...props
|
||||
}: PromptInputHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
}: PromptInputHoverCardProps) => <HoverCard {...props} />;
|
||||
|
||||
export type PromptInputHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
|
||||
@@ -107,7 +107,9 @@ export const SchemaDisplayPath = ({
|
||||
<span
|
||||
className={cn("font-mono text-sm", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(no-danger)
|
||||
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: typeof children === "string" ? children : highlightedPath,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,11 @@ import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
import { listProviders, type Provider } from "@/lib/staff";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
||||
import {
|
||||
DialogStepper,
|
||||
WalletSyncStep,
|
||||
} from "@/components/wallet/wallet-sync-step";
|
||||
|
||||
export type NewAppointment = {
|
||||
fileNumber: string;
|
||||
@@ -51,6 +56,14 @@ const TYPES = [
|
||||
const controlClass =
|
||||
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
|
||||
|
||||
// Local start-of-day so the calendar can disable days strictly before today
|
||||
// (appointments can't be scheduled in the past).
|
||||
const startOfToday = () => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
@@ -70,7 +83,7 @@ export function AddAppointmentDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (appt: NewAppointment) => void;
|
||||
onAdd: (appt: NewAppointment) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -82,6 +95,10 @@ export function AddAppointmentDialog({
|
||||
const [type, setType] = useState(TYPES[0]);
|
||||
const [provider, setProvider] = useState("");
|
||||
const [providerQuery, setProviderQuery] = useState("");
|
||||
const [step, setStep] = useState<"form" | "wallet">("form");
|
||||
const [walletSummary, setWalletSummary] = useState("");
|
||||
|
||||
const sync = useWalletSync(selected?.fileNumber ?? null);
|
||||
|
||||
// Load patients + providers lazily when the dialog opens (for the searches).
|
||||
useEffect(() => {
|
||||
@@ -147,7 +164,16 @@ export function AddAppointmentDialog({
|
||||
setProviderQuery("");
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
onOpenChange(o);
|
||||
if (!o) {
|
||||
reset();
|
||||
setStep("form");
|
||||
sync.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) {
|
||||
notify.error(
|
||||
@@ -156,7 +182,7 @@ export function AddAppointmentDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
await onAdd({
|
||||
fileNumber: selected.fileNumber,
|
||||
name: selected.name,
|
||||
initials: selected.initials,
|
||||
@@ -169,26 +195,36 @@ export function AddAppointmentDialog({
|
||||
t("appointments.dialog.addedTitle"),
|
||||
`${selected.name} · ${time}`,
|
||||
);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
t("walletSync.summary.appointment", { date: keyOf(date), time }),
|
||||
);
|
||||
setStep("wallet");
|
||||
} else {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
if (!o) reset();
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("appointments.dialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("appointments.dialog.description")}
|
||||
</DialogDescription>
|
||||
{sync.linked && <DialogStepper step={step} />}
|
||||
</DialogHeader>
|
||||
|
||||
{step === "wallet" ? (
|
||||
<WalletSyncStep
|
||||
onDone={() => handleOpenChange(false)}
|
||||
patientName={selected?.name ?? ""}
|
||||
summary={walletSummary}
|
||||
sync={sync}
|
||||
/>
|
||||
) : (
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<Field label={t("appointments.dialog.patient")}>
|
||||
@@ -251,6 +287,7 @@ export function AddAppointmentDialog({
|
||||
/>
|
||||
<PopoverPopup>
|
||||
<Calendar
|
||||
disabled={{ before: startOfToday() }}
|
||||
mode="single"
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
@@ -311,6 +348,7 @@ export function AddAppointmentDialog({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { TriangleAlert, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,12 +8,19 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAiConfig } from "@/lib/ai-settings";
|
||||
|
||||
// A single, dismissible heads-up shown above the chat input on a fresh chat when
|
||||
// no AI provider is configured yet (no API key, and no local Ollama). It only
|
||||
// renders on the empty state, so it naturally disappears once a message is sent.
|
||||
// A thin, dismissible warning bar shown flush above the chat input whenever no
|
||||
// AI provider is wired up yet — either no API key for any cloud provider, or the
|
||||
// app is in local mode with no Ollama endpoint set. Without this, sending a
|
||||
// message just fails silently, so the banner spells out the missing setup and
|
||||
// links straight to AI settings. Rendered in both the empty and active chat
|
||||
// states so a user mid-conversation with no provider still sees why replies fail.
|
||||
// Which advisory (if any) to show: the setup nudge (no provider wired), or a
|
||||
// notice that the assistant has been switched off.
|
||||
type Notice = "setup" | "off" | null;
|
||||
|
||||
export function AiSetupNotice() {
|
||||
const { t } = useTranslation();
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,43 +28,60 @@ export function AiSetupNotice() {
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!active) return;
|
||||
// Configured = an API key for any provider, or a local Ollama endpoint.
|
||||
if (cfg.mode === "off") {
|
||||
setNotice("off");
|
||||
return;
|
||||
}
|
||||
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
|
||||
const hasLocal =
|
||||
cfg.mode === "local" && cfg.ollamaBaseUrl.trim().length > 0;
|
||||
setNeedsSetup(!(hasApiKey || hasLocal));
|
||||
// Whether the assistant is actually wired up for the chosen mode:
|
||||
// - api / auto → needs a cloud API key (auto silently falls back to
|
||||
// local, but with no key we nudge the user to finish
|
||||
// setup rather than depend on an unverified Ollama).
|
||||
// - local → needs a non-empty Ollama endpoint (opted in).
|
||||
const configured =
|
||||
cfg.mode === "local"
|
||||
? cfg.ollamaBaseUrl.trim().length > 0
|
||||
: hasApiKey;
|
||||
setNotice(configured ? null : "setup");
|
||||
})
|
||||
.catch(() => {
|
||||
// If we can't read the config, don't nag — the chat still works.
|
||||
if (active) setNeedsSetup(false);
|
||||
if (active) setNotice(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!needsSetup || dismissed) return null;
|
||||
if (!notice || dismissed) return null;
|
||||
|
||||
const isOff = notice === "off";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-start gap-3 rounded-2xl border border-info/30 bg-info/8 px-4 py-3 text-sm dark:bg-info/12"
|
||||
className="flex w-full items-center gap-2.5 rounded-2xl border border-warning/32 bg-warning/8 px-3.5 py-2 text-sm dark:bg-warning/12"
|
||||
role="status"
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-info-foreground" />
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("chat.setupNotice.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("chat.setupNotice.body")}</p>
|
||||
<Button
|
||||
className="mt-1 px-0 text-info-foreground"
|
||||
render={<Link href="/settings?tab=ai" />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{t("chat.setupNotice.action")}
|
||||
</Button>
|
||||
</div>
|
||||
<TriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
<p className="min-w-0 flex-1 truncate text-foreground">
|
||||
<span className="font-medium">
|
||||
{isOff
|
||||
? t("chat.setupNotice.offTitle")
|
||||
: t("chat.setupNotice.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground max-sm:hidden">
|
||||
{" — "}
|
||||
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
render={<Link href="/settings?tab=ai" />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("chat.setupNotice.action")}
|
||||
</Button>
|
||||
<button
|
||||
aria-label={t("chat.setupNotice.dismiss")}
|
||||
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
|
||||
@@ -188,7 +188,17 @@ export function ChatPanel() {
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return;
|
||||
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
|
||||
// Seed the model to match the configured mode. In "auto" we prefer the
|
||||
// user's cloud default when a key exists, else fall back to the local
|
||||
// sentinel (mirrors the backend's provider resolution).
|
||||
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
|
||||
const seeded =
|
||||
cfg.mode === "local"
|
||||
? "ollama"
|
||||
: cfg.mode === "auto" && !hasApiKey
|
||||
? "ollama"
|
||||
: cfg.defaultModel;
|
||||
setModel(seeded);
|
||||
setEffort(cfg.defaultEffort);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -687,6 +697,22 @@ export function ChatPanel() {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Fallback: a structured card the client doesn't recognize (e.g. a
|
||||
// data part added or renamed on the backend). Surface a small
|
||||
// placeholder so a written-but-unhandled result is never silently
|
||||
// invisible. `data-step`/`data-source` intentionally render nothing
|
||||
// here (they're consumed above), so skip them.
|
||||
if (
|
||||
part.type.startsWith("data-") &&
|
||||
part.type !== "data-step" &&
|
||||
part.type !== "data-source"
|
||||
) {
|
||||
return (
|
||||
<Badge className="self-start" key={key} variant="outline">
|
||||
{t("chat.card.unsupported")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
@@ -723,8 +749,7 @@ export function ChatPanel() {
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{/* One-time setup heads-up — only on the empty state, so it clears
|
||||
itself once the first message is sent. */}
|
||||
{/* Setup heads-up when no AI provider is configured. */}
|
||||
<AiSetupNotice />
|
||||
{promptInput}
|
||||
<Suggestions className="justify-center pt-1">
|
||||
@@ -761,6 +786,9 @@ export function ChatPanel() {
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{queuePanel}
|
||||
{/* Also warn mid-conversation when no AI provider is configured, so
|
||||
failing replies have a visible cause and a fix. */}
|
||||
<AiSetupNotice />
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -243,6 +243,11 @@ function SummaryCard({
|
||||
label={t("patientCard.summary.allergies")}
|
||||
value={patient.allergies.length || t("patientCard.summary.none")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.bloodType")}
|
||||
value={patient.bloodType || "—"}
|
||||
/>
|
||||
<Stat label={t("patientCard.summary.phone")} value={patient.phone || "—"} />
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
{onEdit ? (
|
||||
|
||||
@@ -38,6 +38,11 @@ import { uploadAttachment } from "@/lib/attachments";
|
||||
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||
import { listProviders, type Provider } from "@/lib/staff";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
||||
import {
|
||||
DialogStepper,
|
||||
WalletSyncStep,
|
||||
} from "@/components/wallet/wallet-sync-step";
|
||||
|
||||
type PatientFormDialogProps = {
|
||||
open: boolean;
|
||||
@@ -233,6 +238,19 @@ export function PatientFormDialog({
|
||||
const [fileNumber, setFileNumber] = useState(() =>
|
||||
isEdit && patient ? patient.fileNumber : generateFileNumber()
|
||||
);
|
||||
const [step, setStep] = useState<"form" | "wallet">("form");
|
||||
|
||||
// Only edits to an existing (non-review) record can sync to a wallet — a newly
|
||||
// created patient has no wallet, and review mode stages an import.
|
||||
const sync = useWalletSync(isEdit && !isReview ? fileNumber : null);
|
||||
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
onOpenChange(o);
|
||||
if (!o) {
|
||||
setStep("form");
|
||||
sync.reset();
|
||||
}
|
||||
};
|
||||
const [name, setName] = useState(patient?.name ?? "");
|
||||
const [age, setAge] = useState(patient ? String(patient.age) : "");
|
||||
const [sex, setSex] = useState<Patient["sex"]>(patient?.sex ?? "F");
|
||||
@@ -243,6 +261,8 @@ export function PatientFormDialog({
|
||||
// per-doctor visibility), not free text. `providerId` is the selected user id.
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? "");
|
||||
const [phone, setPhone] = useState(patient?.phone ?? "");
|
||||
const [bloodType, setBloodType] = useState(patient?.bloodType ?? "");
|
||||
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
|
||||
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
|
||||
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
|
||||
@@ -310,6 +330,8 @@ export function PatientFormDialog({
|
||||
primaryProviderId: providerId || null,
|
||||
status,
|
||||
initials: initialsFromName(name),
|
||||
phone: phone.trim(),
|
||||
bloodType,
|
||||
allergies: allergies.filter((a) => a.substance.trim()),
|
||||
alerts: patient?.alerts ?? [],
|
||||
medications: medications.filter((m) => m.name.trim()),
|
||||
@@ -374,6 +396,10 @@ export function PatientFormDialog({
|
||||
t("patientForm.updatedTitle"),
|
||||
t("patientForm.updatedBody", { name: saved.name }),
|
||||
);
|
||||
if (await sync.ensureLinked()) {
|
||||
setStep("wallet");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
onCreated?.(saved.fileNumber);
|
||||
notify.success(
|
||||
@@ -396,8 +422,8 @@ export function PatientFormDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isReview
|
||||
@@ -415,8 +441,17 @@ export function PatientFormDialog({
|
||||
})
|
||||
: t("patientForm.createDescription")}
|
||||
</DialogDescription>
|
||||
{sync.linked && <DialogStepper step={step} />}
|
||||
</DialogHeader>
|
||||
|
||||
{step === "wallet" ? (
|
||||
<WalletSyncStep
|
||||
onDone={() => handleOpenChange(false)}
|
||||
patientName={name.trim()}
|
||||
summary={t("walletSync.summary.demographics")}
|
||||
sync={sync}
|
||||
/>
|
||||
) : (
|
||||
<form className="contents" onSubmit={handleSubmit}>
|
||||
<DialogPanel
|
||||
scrollFade={false}
|
||||
@@ -515,6 +550,31 @@ export function PatientFormDialog({
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("patientForm.phone")}>
|
||||
<Input
|
||||
inputMode="tel"
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder={t("patientForm.phonePlaceholder")}
|
||||
value={phone}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("patientForm.bloodType")}>
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) => setBloodType(event.target.value)}
|
||||
value={bloodType}
|
||||
>
|
||||
<option value="">{t("patientForm.bloodTypeUnknown")}</option>
|
||||
{["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"].map((bt) => (
|
||||
<option key={bt} value={bt}>
|
||||
{bt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{showClinical && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -740,6 +800,7 @@ export function PatientFormDialog({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -35,6 +36,11 @@ import {
|
||||
} from "@/lib/invoices";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
||||
import {
|
||||
DialogStepper,
|
||||
WalletSyncStep,
|
||||
} from "@/components/wallet/wallet-sync-step";
|
||||
|
||||
const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "void"];
|
||||
|
||||
@@ -59,12 +65,23 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Local start-of-day, used to disable days strictly before today.
|
||||
const startOfToday = () => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
allowPast = true,
|
||||
}: {
|
||||
value: Date;
|
||||
onChange: (d: Date) => void;
|
||||
// When false, days before today are disabled (used for the issue date unless
|
||||
// the clinician opts into back-dating an older invoice).
|
||||
allowPast?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -87,6 +104,7 @@ function DatePicker({
|
||||
/>
|
||||
<PopoverPopup>
|
||||
<Calendar
|
||||
disabled={allowPast ? undefined : { before: startOfToday() }}
|
||||
mode="single"
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
@@ -131,18 +149,36 @@ export function InvoiceFormDialog({
|
||||
: null;
|
||||
|
||||
const [issuedAt, setIssuedAt] = useState<Date>(() => new Date());
|
||||
// Off by default: the issue date can't be back-dated unless the clinician
|
||||
// opts in (for recording an older, pre-existing invoice).
|
||||
const [allowBackdate, setAllowBackdate] = useState(false);
|
||||
const [hasDue, setHasDue] = useState(false);
|
||||
const [dueAt, setDueAt] = useState<Date>(() => new Date());
|
||||
const [status, setStatus] = useState<InvoiceStatus>("draft");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [lineItems, setLineItems] = useState<InvoiceLineItem[]>([emptyLine()]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [step, setStep] = useState<"form" | "wallet">("form");
|
||||
const [walletSummary, setWalletSummary] = useState("");
|
||||
|
||||
const activePatient = fixedPatient ?? selected;
|
||||
const sync = useWalletSync(activePatient?.fileNumber ?? null);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setStep("form");
|
||||
sync.reset();
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
// Seed the form when opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "edit" && invoice) {
|
||||
setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`));
|
||||
// Existing invoices legitimately carry past issue dates.
|
||||
setAllowBackdate(true);
|
||||
setHasDue(Boolean(invoice.dueAt));
|
||||
setDueAt(new Date(`${invoice.dueAt ?? invoice.issuedAt}T00:00:00`));
|
||||
setStatus(invoice.status);
|
||||
@@ -153,6 +189,7 @@ export function InvoiceFormDialog({
|
||||
} else {
|
||||
setSelected(null);
|
||||
setIssuedAt(new Date());
|
||||
setAllowBackdate(false);
|
||||
setHasDue(false);
|
||||
setDueAt(new Date());
|
||||
setStatus("draft");
|
||||
@@ -236,7 +273,16 @@ export function InvoiceFormDialog({
|
||||
})
|
||||
: await createInvoice(payload);
|
||||
onSaved(saved);
|
||||
onOpenChange(false);
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
mode === "edit"
|
||||
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
|
||||
: t("walletSync.summary.invoiceCreated", { number: saved.number }),
|
||||
);
|
||||
setStep("wallet");
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("invoices.addFailedTitle"), t("invoices.addFailedBody"));
|
||||
} finally {
|
||||
@@ -245,8 +291,8 @@ export function InvoiceFormDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-lg">
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "edit"
|
||||
@@ -256,8 +302,17 @@ export function InvoiceFormDialog({
|
||||
<DialogDescription>
|
||||
{t("invoices.dialog.description")}
|
||||
</DialogDescription>
|
||||
{sync.linked && <DialogStepper step={step} />}
|
||||
</DialogHeader>
|
||||
|
||||
{step === "wallet" ? (
|
||||
<WalletSyncStep
|
||||
onDone={() => handleOpenChange(false)}
|
||||
patientName={activePatient?.name ?? ""}
|
||||
summary={walletSummary}
|
||||
sync={sync}
|
||||
/>
|
||||
) : (
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<Field label={t("invoices.dialog.patient")}>
|
||||
@@ -310,17 +365,34 @@ export function InvoiceFormDialog({
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("invoices.dialog.issued")}>
|
||||
<DatePicker onChange={setIssuedAt} value={issuedAt} />
|
||||
</Field>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.issued")}
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px]"
|
||||
htmlFor="invoice-backdate"
|
||||
>
|
||||
<Checkbox
|
||||
checked={allowBackdate}
|
||||
id="invoice-backdate"
|
||||
onCheckedChange={(checked) => setAllowBackdate(checked)}
|
||||
/>
|
||||
{t("invoices.dialog.backdate")}
|
||||
</label>
|
||||
</span>
|
||||
<DatePicker
|
||||
allowPast={allowBackdate}
|
||||
onChange={setIssuedAt}
|
||||
value={issuedAt}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.due")}
|
||||
<input
|
||||
<Checkbox
|
||||
aria-label={t("invoices.dialog.due")}
|
||||
checked={hasDue}
|
||||
onChange={(e) => setHasDue(e.target.checked)}
|
||||
type="checkbox"
|
||||
onCheckedChange={(checked) => setHasDue(checked)}
|
||||
/>
|
||||
</span>
|
||||
{hasDue ? (
|
||||
@@ -445,6 +517,7 @@ export function InvoiceFormDialog({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -141,6 +141,8 @@ export function ImportFromWalletDialog({
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
setError(t("patients.importApp.invalidWallet"));
|
||||
} else if (err instanceof ApiError && err.status === 409) {
|
||||
setError(t("patients.importApp.networkOff"));
|
||||
} else {
|
||||
setError(t("patients.importApp.error"));
|
||||
}
|
||||
@@ -171,8 +173,12 @@ export function ImportFromWalletDialog({
|
||||
setPairUri(`temetro-pair:?${params.toString()}`);
|
||||
setRequest(pairing);
|
||||
setPhase("waiting");
|
||||
} catch {
|
||||
setError(t("patients.importApp.error"));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setError(t("patients.importApp.networkOff"));
|
||||
} else {
|
||||
setError(t("patients.importApp.error"));
|
||||
}
|
||||
setPhase("error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
CalendarDays,
|
||||
FileDown,
|
||||
type LucideIcon,
|
||||
ListTodo,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
Pill,
|
||||
Send,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,10 +22,21 @@ import { useTranslation } from "react-i18next";
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { AttachmentsSection } from "@/components/patients/patient-files";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { type ActivityEntry, listPatientActivity } from "@/lib/activity";
|
||||
import {
|
||||
type ActivityEntityType,
|
||||
type ActivityEntry,
|
||||
listPatientActivity,
|
||||
} from "@/lib/activity";
|
||||
import { printPatientSummary } from "@/lib/patient-pdf";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -122,8 +140,18 @@ function TrendBlock({ trend }: { trend: Trend }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Which icon marks each kind of audited change on the timeline.
|
||||
const historyIcon: Record<ActivityEntityType, LucideIcon> = {
|
||||
appointment: CalendarDays,
|
||||
note: NotebookPen,
|
||||
patient: UserRound,
|
||||
prescription: Pill,
|
||||
task: ListTodo,
|
||||
};
|
||||
|
||||
// The patient's record history: every audited add/change on this chart, newest
|
||||
// first. Reuses the clinic activity log scoped to this file number.
|
||||
// first. Reuses the clinic activity log scoped to this file number, laid out as
|
||||
// a vertical timeline — who made the change, what happened, and when.
|
||||
function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
|
||||
@@ -152,24 +180,40 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
||||
{t("patientCard.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{entries.map((e) => (
|
||||
<li className="flex items-start gap-3" key={e.id}>
|
||||
<Avatar className="mt-0.5 size-7 shrink-0">
|
||||
<AvatarFallback className="text-[11px]">
|
||||
{e.actorInitials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-foreground text-sm">
|
||||
<span className="font-medium">{e.actorName}</span> {e.action}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
// A plain vertical rail so EVERY audited change renders in full — the
|
||||
// icon column draws a connector that stretches to the next entry, and
|
||||
// the flex layout mirrors correctly under RTL.
|
||||
<ol className="space-y-0">
|
||||
{entries.map((e, i) => {
|
||||
const Icon = historyIcon[e.entityType] ?? Pencil;
|
||||
const isLast = i === entries.length - 1;
|
||||
return (
|
||||
<li className="flex gap-3" key={e.id}>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="my-1 w-px flex-1 bg-primary/15"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`min-w-0 flex-1 ${isLast ? "" : "pb-5"}`}
|
||||
>
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{e.actorName}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">{e.action}</p>
|
||||
<time className="mt-0.5 block font-medium text-muted-foreground text-xs">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
@@ -259,6 +303,8 @@ export function PatientDetail({
|
||||
<AvatarFallback>{patient.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/* Name, status, and the overflow menu share one left-aligned row;
|
||||
Edit now lives inside the menu rather than as a separate button. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-base text-foreground">
|
||||
{patient.name}
|
||||
@@ -266,6 +312,59 @@ export function PatientDetail({
|
||||
<Badge variant={statusVariant[patient.status]}>
|
||||
{t(`patients.status.${patient.status}`)}
|
||||
</Badge>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patientCard.moreActions")}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start">
|
||||
{onEdit && (
|
||||
<MenuItem onClick={onEdit}>
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => printPatientSummary(patient, t)}>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</MenuItem>
|
||||
{onScribe && (
|
||||
<MenuItem onClick={onScribe}>
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onTransfer && (
|
||||
<MenuItem onClick={onTransfer}>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<MenuItem onClick={onWalletPush}>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onDelete} variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("patients.delete.action")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">{idLine}</span>
|
||||
{patient.alerts.length > 0 && (
|
||||
@@ -279,64 +378,6 @@ export function PatientDetail({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions — their own wrapping row beneath the identity. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => printPatientSummary(patient, t)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</Button>
|
||||
{onTransfer && (
|
||||
<Button
|
||||
onClick={onTransfer}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onScribe && (
|
||||
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</Button>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<Button
|
||||
onClick={onWalletPush}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
className="ms-auto"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title={t("patientCard.overview")}>
|
||||
@@ -359,6 +400,14 @@ export function PatientDetail({
|
||||
label={t("patientCard.summary.openProblems")}
|
||||
value={patient.problems.length}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.bloodType")}
|
||||
value={patient.bloodType || "—"}
|
||||
/>
|
||||
<Stat
|
||||
label={t("patientCard.summary.phone")}
|
||||
value={patient.phone || "—"}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Search, Smartphone } from "lucide-react";
|
||||
import { MoreHorizontal, Plus, Search, Smartphone } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,10 +11,40 @@ import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet
|
||||
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardFrame } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListPagination } from "@/components/ui/list-pagination";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectPopup,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
|
||||
type StatusFilter = "all" | Patient["status"];
|
||||
const STATUS_FILTERS: StatusFilter[] = [
|
||||
"all",
|
||||
"active",
|
||||
"inpatient",
|
||||
"discharged",
|
||||
];
|
||||
|
||||
// Rows shown per page on the patients table before paginating.
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -32,6 +62,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
export function PatientsView() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
// Bumped on open so the create dialog remounts with a fresh file # / form.
|
||||
@@ -69,9 +100,18 @@ export function PatientsView() {
|
||||
}, []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const patients = allPatients.filter(
|
||||
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
|
||||
);
|
||||
// Search matches name, MRN, problem/condition labels, and allergy substances;
|
||||
// the status filter narrows to one clinical status.
|
||||
const patients = allPatients.filter((p) => {
|
||||
if (statusFilter !== "all" && p.status !== statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.fileNumber.includes(q) ||
|
||||
p.problems.some((problem) => problem.label.toLowerCase().includes(q)) ||
|
||||
p.allergies.some((a) => a.substance.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
// Client-side pagination over the filtered list (10/page). Searching resets to
|
||||
// the first page (done in the search handler); `page` is clamped at render so a
|
||||
@@ -133,15 +173,6 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setImportOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => {
|
||||
@@ -153,58 +184,119 @@ export function PatientsView() {
|
||||
<Plus className="size-4" />
|
||||
{t("patients.add")}
|
||||
</Button>
|
||||
{/* Secondary actions tuck into an overflow menu so the toolbar keeps a
|
||||
single primary CTA. */}
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patients.moreActions")}
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end">
|
||||
<MenuItem onClick={() => setImportOpen(true)}>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
{/* Filter sits on its own row just above the table so the toolbar stays a
|
||||
clean title + search + primary actions cluster. */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("patients.filterLabel")}
|
||||
</span>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("patients.filterStatus")}
|
||||
className="w-40"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{STATUS_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === "all"
|
||||
? t("patients.allStatuses")
|
||||
: t(`patients.status.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<CardFrame className="mt-4 w-full">
|
||||
<Table variant="card">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="ps-4 text-xs uppercase">
|
||||
{t("patients.columns.name")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.mrn")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.ageSex")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.lastSeen")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="pe-4 text-xs uppercase">
|
||||
{t("patients.columns.allergies")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : loadError ? (
|
||||
<tr>
|
||||
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-destructive"
|
||||
colSpan={6}
|
||||
>
|
||||
{loadError}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : patients.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageRows.map((p) => (
|
||||
<tr
|
||||
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={p.fileNumber}
|
||||
onClick={() => open(p.fileNumber)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -216,7 +308,7 @@ export function PatientsView() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<TableCell className="ps-4 py-3 font-medium text-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<AiBadge source={p.source} />
|
||||
@@ -226,30 +318,30 @@ export function PatientsView() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.fileNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.age} · {p.sex}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<Badge variant={statusVariant[p.status]}>
|
||||
{t(`patients.status.${p.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.encounters[0]?.date ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="pe-4 py-3 text-muted-foreground">
|
||||
{p.allergies.length || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardFrame>
|
||||
|
||||
{!loading && !loadError ? (
|
||||
<ListPagination
|
||||
|
||||
@@ -29,6 +29,11 @@ import type { Encounter, Patient } from "@/lib/patients";
|
||||
import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
||||
import {
|
||||
DialogStepper,
|
||||
WalletSyncStep,
|
||||
} from "@/components/wallet/wallet-sync-step";
|
||||
|
||||
type Phase = "input" | "processing" | "review";
|
||||
type InputTab = "record" | "paste";
|
||||
@@ -76,6 +81,9 @@ export function ScribeDialog({
|
||||
const [draft, setDraft] = useState<Encounter | null>(null);
|
||||
const [veilNote, setVeilNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [walletStep, setWalletStep] = useState(false);
|
||||
|
||||
const sync = useWalletSync(patient.fileNumber);
|
||||
|
||||
const mediaRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
@@ -105,6 +113,8 @@ export function ScribeDialog({
|
||||
setDraft(null);
|
||||
setVeilNote(null);
|
||||
setError(null);
|
||||
setWalletStep(false);
|
||||
sync.reset();
|
||||
chunksRef.current = [];
|
||||
blobRef.current = null;
|
||||
};
|
||||
@@ -223,7 +233,12 @@ export function ScribeDialog({
|
||||
const updated = await saveNote(patient.fileNumber, draft);
|
||||
notify.success(t("scribe.saved.title"), patient.name);
|
||||
onSaved(updated);
|
||||
handleOpenChange(false);
|
||||
if (await sync.ensureLinked()) {
|
||||
setPhase("review");
|
||||
setWalletStep(true);
|
||||
} else {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
|
||||
@@ -245,8 +260,20 @@ export function ScribeDialog({
|
||||
<DialogDescription>
|
||||
{t("scribe.subtitle", { name: patient.name })}
|
||||
</DialogDescription>
|
||||
{sync.linked && (
|
||||
<DialogStepper step={walletStep ? "wallet" : "form"} />
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{walletStep ? (
|
||||
<WalletSyncStep
|
||||
onDone={() => handleOpenChange(false)}
|
||||
patientName={patient.name}
|
||||
summary={t("walletSync.summary.note")}
|
||||
sync={sync}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
||||
{phase === "review" && draft ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -430,6 +457,8 @@ export function ScribeDialog({
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import QRCodeSvg from "react-qr-code";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -25,13 +27,15 @@ import {
|
||||
bookPortalAppointment,
|
||||
createPortalPatient,
|
||||
getPortalClinic,
|
||||
getPortalLink,
|
||||
lookupPortalResults,
|
||||
portalPairingUri,
|
||||
type PortalBookingResult,
|
||||
type PortalResults,
|
||||
} from "@/lib/portal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "choose" | "book" | "results";
|
||||
type Step = "choose" | "book" | "results" | "wallet";
|
||||
|
||||
const todayKey = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
@@ -93,6 +97,8 @@ export function PortalKiosk({ clinic }: { clinic: string }) {
|
||||
<ChooseStep onPick={setStep} />
|
||||
) : step === "book" ? (
|
||||
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
) : step === "wallet" ? (
|
||||
<WalletStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
) : (
|
||||
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
|
||||
)}
|
||||
@@ -117,6 +123,12 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
||||
title: t("portal.choose.resultsTitle"),
|
||||
desc: t("portal.choose.resultsDesc"),
|
||||
},
|
||||
{
|
||||
step: "wallet",
|
||||
icon: <Smartphone className="size-7" />,
|
||||
title: t("portal.choose.walletTitle"),
|
||||
desc: t("portal.choose.walletDesc"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2">
|
||||
@@ -157,6 +169,47 @@ function BackButton({ onBack }: { onBack: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Show a QR the patient scans with the temetro wallet app to link it to this
|
||||
// clinic over the Temetro Network relay. After linking, the app can book
|
||||
// appointments and view/download results itself, syncing back to the clinic.
|
||||
function WalletStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [uri, setUri] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getPortalLink(clinic)
|
||||
.then((link) => active && setUri(portalPairingUri(link)))
|
||||
.catch(() => active && setError(true));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [clinic]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-5">
|
||||
<BackButton onBack={onBack} />
|
||||
<h2 className="font-semibold text-xl">{t("portal.wallet.title")}</h2>
|
||||
<p className="max-w-md text-center text-muted-foreground text-sm">
|
||||
{t("portal.wallet.subtitle")}
|
||||
</p>
|
||||
{uri ? (
|
||||
<div className="rounded-2xl bg-white p-4">
|
||||
<QRCodeSvg value={uri} size={232} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-destructive text-sm">{t("portal.wallet.error")}</p>
|
||||
) : (
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<p className="max-w-md text-center text-muted-foreground text-xs">
|
||||
{t("portal.wallet.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
// "returning" = has a file number; "new" = register first, then book.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -29,6 +30,11 @@ import { type InventoryItem, listInventory } from "@/lib/inventory";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
||||
import {
|
||||
DialogStepper,
|
||||
WalletSyncStep,
|
||||
} from "@/components/wallet/wallet-sync-step";
|
||||
|
||||
export type NewPrescription = {
|
||||
fileNumber: string;
|
||||
@@ -132,7 +138,7 @@ export function AddPrescriptionDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (rx: NewPrescription) => void;
|
||||
onAdd: (rx: NewPrescription) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -151,6 +157,19 @@ export function AddPrescriptionDialog({
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [step, setStep] = useState<"form" | "wallet">("form");
|
||||
const [walletSummary, setWalletSummary] = useState("");
|
||||
|
||||
const sync = useWalletSync(selected?.fileNumber ?? null);
|
||||
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
onOpenChange(o);
|
||||
if (!o) {
|
||||
reset();
|
||||
setStep("form");
|
||||
sync.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Load patients + inventory lazily when the dialog opens (for the quick
|
||||
// searches). Inventory backs the medication combobox; it still accepts free
|
||||
@@ -279,7 +298,7 @@ export function AddPrescriptionDialog({
|
||||
setNotes("");
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) {
|
||||
notify.error(
|
||||
@@ -312,7 +331,7 @@ export function AddPrescriptionDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
await onAdd({
|
||||
fileNumber: selected.fileNumber,
|
||||
name: selected.name,
|
||||
initials: selected.initials,
|
||||
@@ -328,26 +347,36 @@ export function AddPrescriptionDialog({
|
||||
t("prescriptions.dialog.addedTitle"),
|
||||
`${medication.trim()} · ${selected.name}`,
|
||||
);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
if (await sync.ensureLinked()) {
|
||||
setWalletSummary(
|
||||
t("walletSync.summary.prescription", { drug: medication.trim() }),
|
||||
);
|
||||
setStep("wallet");
|
||||
} else {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
if (!o) reset();
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("prescriptions.dialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("prescriptions.dialog.description")}
|
||||
</DialogDescription>
|
||||
{sync.linked && <DialogStepper step={step} />}
|
||||
</DialogHeader>
|
||||
|
||||
{step === "wallet" ? (
|
||||
<WalletSyncStep
|
||||
onDone={() => handleOpenChange(false)}
|
||||
patientName={selected?.name ?? ""}
|
||||
summary={walletSummary}
|
||||
sync={sync}
|
||||
/>
|
||||
) : (
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<Field label={t("prescriptions.dialog.patient")}>
|
||||
@@ -550,19 +579,17 @@ export function AddPrescriptionDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("prescriptions.dialog.startDate")}>
|
||||
<input
|
||||
className={controlClass}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
type="date"
|
||||
<DatePicker
|
||||
onChange={setStartDate}
|
||||
placeholder={t("prescriptions.dialog.selectDate")}
|
||||
value={startDate}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("prescriptions.dialog.endDate")}>
|
||||
<input
|
||||
className={controlClass}
|
||||
<DatePicker
|
||||
min={startDate || undefined}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
type="date"
|
||||
onChange={setEndDate}
|
||||
placeholder={t("prescriptions.dialog.selectDate")}
|
||||
value={endDate}
|
||||
/>
|
||||
</Field>
|
||||
@@ -616,6 +643,7 @@ export function AddPrescriptionDialog({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SettingsFrame,
|
||||
ToggleRow,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
@@ -41,7 +40,7 @@ const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
|
||||
|
||||
const DEFAULTS: AiConfig = {
|
||||
mode: "local",
|
||||
mode: "auto",
|
||||
provider: "anthropic",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
ollamaModel: "llama3.1",
|
||||
@@ -196,15 +195,26 @@ export function AIPanel() {
|
||||
|
||||
const keyIsSet = config.apiKeySet[config.provider];
|
||||
|
||||
// Which mode-hint copy to show under the Mode select.
|
||||
const modeHintSuffix =
|
||||
config.mode === "api"
|
||||
? "Api"
|
||||
: config.mode === "local"
|
||||
? "Local"
|
||||
: config.mode === "off"
|
||||
? "Off"
|
||||
: "Auto";
|
||||
|
||||
return (
|
||||
<>
|
||||
{policy ? (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-3"
|
||||
description={t("settings.ai.availability.description")}
|
||||
title={t("settings.ai.availability.title")}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
<ToggleRow
|
||||
checked={policy.aiEnabled}
|
||||
description={t("settings.ai.availability.enabledHint")}
|
||||
@@ -240,58 +250,67 @@ export function AIPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SettingsCard className="px-4 py-3.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
? t("settings.ai.availability.readonlyEmployeesOnly")
|
||||
: t("settings.ai.availability.readonlyEnabled")
|
||||
: t("settings.ai.availability.readonlyDisabled")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
? t("settings.ai.availability.readonlyEmployeesOnly")
|
||||
: t("settings.ai.availability.readonlyEnabled")
|
||||
: t("settings.ai.availability.readonlyDisabled")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.modeDescription")}
|
||||
title={t("settings.ai.modeTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("mode", value as AiMode)}
|
||||
value={config.mode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "api"
|
||||
? t("settings.ai.modeApiHint")
|
||||
: t("settings.ai.modeLocalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("mode", value as AiMode)}
|
||||
value={config.mode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
<SelectItem value="auto">
|
||||
{t("settings.ai.modeAuto")}
|
||||
</SelectItem>
|
||||
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
|
||||
<SelectItem value="local">
|
||||
{t("settings.ai.modeLocal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">{t("settings.ai.modeOff")}</SelectItem>
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsFrame>
|
||||
|
||||
{config.mode === "api" ? (
|
||||
<SettingsSection
|
||||
{config.mode === "off" ? (
|
||||
<SettingsFrame
|
||||
description={t("settings.ai.offDescription")}
|
||||
title={t("settings.ai.offTitle")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.ai.offNote")}
|
||||
</p>
|
||||
</SettingsFrame>
|
||||
) : config.mode !== "local" ? (
|
||||
// API and Automatic both configure a cloud provider (Automatic falls
|
||||
// back to local Ollama when no key is set).
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-5"
|
||||
description={t("settings.ai.providerDescription")}
|
||||
title={t("settings.ai.providerTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
|
||||
<Select
|
||||
@@ -377,15 +396,14 @@ export function AIPanel() {
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
) : (
|
||||
<SettingsSection
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-5"
|
||||
description={t("settings.ai.localDescription")}
|
||||
title={t("settings.ai.localTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-5 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
|
||||
<Input
|
||||
@@ -414,40 +432,40 @@ export function AIPanel() {
|
||||
? t("settings.ai.testing")
|
||||
: t("settings.ai.testConnection")}
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</SettingsFrame>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
{config.mode !== "off" ? (
|
||||
<SettingsFrame
|
||||
bodyClassName="space-y-4"
|
||||
description={t("settings.ai.veilDescription")}
|
||||
title={t("settings.ai.veilTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
|
||||
value={config.veilLevel}
|
||||
>
|
||||
<SelectTrigger className="sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{VEIL_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`settings.ai.veilLevels.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "local"
|
||||
? t("settings.ai.veilLocalNote")
|
||||
: t("settings.ai.veilApiNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
|
||||
<Select
|
||||
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
|
||||
value={config.veilLevel}
|
||||
>
|
||||
<SelectTrigger className="sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{VEIL_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{t(`settings.ai.veilLevels.${level}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.mode === "local"
|
||||
? t("settings.ai.veilLocalNote")
|
||||
: t("settings.ai.veilApiNote")}
|
||||
</p>
|
||||
</SettingsFrame>
|
||||
) : null}
|
||||
|
||||
{dirty ? (
|
||||
<div className="sticky bottom-4 z-10">
|
||||
|
||||
@@ -6,16 +6,21 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
CopyField,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { ClinicLocationSection } from "@/components/settings/settings-location";
|
||||
import { PatientPortalSection } from "@/components/settings/settings-portal";
|
||||
import {
|
||||
getNetworkEnabled,
|
||||
getSigningKey,
|
||||
listSignedRecords,
|
||||
rotateSigningKey,
|
||||
setNetworkEnabled,
|
||||
type SharedRecord,
|
||||
type SigningKey,
|
||||
} from "@/lib/signing";
|
||||
@@ -38,6 +43,8 @@ export function SigningPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [networkOn, setNetworkOn] = useState(false);
|
||||
const [networkSaving, setNetworkSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -45,12 +52,14 @@ export function SigningPanel() {
|
||||
getSigningKey(),
|
||||
listSignedRecords().catch(() => []),
|
||||
listWalletUpdates().catch(() => []),
|
||||
getNetworkEnabled().catch(() => false),
|
||||
])
|
||||
.then(([k, r, u]) => {
|
||||
.then(([k, r, u, n]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setUpdates(u);
|
||||
setNetworkOn(n);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -83,6 +92,32 @@ export function SigningPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNetwork = async (next: boolean) => {
|
||||
setNetworkSaving(true);
|
||||
// Optimistic — revert on failure.
|
||||
setNetworkOn(next);
|
||||
try {
|
||||
const saved = await setNetworkEnabled(next);
|
||||
setNetworkOn(saved);
|
||||
notify.success(
|
||||
next
|
||||
? t("settings.network.joinedTitle")
|
||||
: t("settings.network.leftTitle"),
|
||||
next
|
||||
? t("settings.network.joinedBody")
|
||||
: t("settings.network.leftBody"),
|
||||
);
|
||||
} catch {
|
||||
setNetworkOn(!next);
|
||||
notify.error(
|
||||
t("settings.network.errorTitle"),
|
||||
t("settings.network.error"),
|
||||
);
|
||||
} finally {
|
||||
setNetworkSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recordStatusLabel = (status: SharedRecord["status"]): string =>
|
||||
t(
|
||||
`settings.signing.records.status${
|
||||
@@ -137,6 +172,45 @@ export function SigningPanel() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.network.description")}
|
||||
title={t("settings.network.title")}
|
||||
>
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.network.toggleLabel")}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
networkOn
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{networkOn
|
||||
? t("settings.network.statusConnected")
|
||||
: t("settings.network.statusOff")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.network.toggleDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t("settings.network.toggleLabel")}
|
||||
checked={networkOn}
|
||||
disabled={loading || networkSaving}
|
||||
onCheckedChange={toggleNetwork}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<PatientPortalSection />
|
||||
|
||||
<ClinicLocationSection />
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.signing.identityDescription")}
|
||||
title={t("settings.signing.identityTitle")}
|
||||
@@ -177,7 +251,7 @@ export function SigningPanel() {
|
||||
description={t("settings.signing.backupDescription")}
|
||||
title={t("settings.signing.backupTitle")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-4">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.signing.backupLabel")}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { LocateFixed } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getClinicSettings, saveClinicLocation } from "@/lib/clinic";
|
||||
import { reverseGeocode } from "@/lib/geocode";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// Parse a coordinate input into a number or null (empty ⇒ null). Returns
|
||||
// `false` when the string is present but not a finite number, so we can flag it.
|
||||
function parseCoord(value: string): number | null | false {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : false;
|
||||
}
|
||||
|
||||
// Clinic location editor (owner/admin only — mounted inside the Signing panel).
|
||||
// Persists the clinic's address + optional map coordinates so the wallet app can
|
||||
// display the clinic location later.
|
||||
export function ClinicLocationSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
const [latitude, setLatitude] = useState("");
|
||||
const [longitude, setLongitude] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getClinicSettings()
|
||||
.then((settings) => {
|
||||
if (!active) return;
|
||||
const loc = settings.location;
|
||||
setAddress(loc.address);
|
||||
setCity(loc.city);
|
||||
setCountry(loc.country);
|
||||
setLatitude(loc.latitude === null ? "" : String(loc.latitude));
|
||||
setLongitude(loc.longitude === null ? "" : String(loc.longitude));
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep empty defaults */
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fill the location from the browser's geolocation (the clinician runs this on
|
||||
// a device at the clinic): sets the coordinates, then reverse-geocodes them to
|
||||
// also fill address / city / country. The address lookup is best-effort — on
|
||||
// failure we keep the coordinates and just tell the user to fill the rest.
|
||||
const useMyLocation = () => {
|
||||
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
|
||||
notify.error(
|
||||
t("settings.location.errorTitle"),
|
||||
t("settings.location.geoUnsupported"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setLatitude(lat.toFixed(6));
|
||||
setLongitude(lng.toFixed(6));
|
||||
const place = await reverseGeocode(lat, lng, i18n.language);
|
||||
setLocating(false);
|
||||
if (place) {
|
||||
if (place.address) setAddress(place.address);
|
||||
if (place.city) setCity(place.city);
|
||||
if (place.country) setCountry(place.country);
|
||||
} else {
|
||||
notify.info(
|
||||
t("settings.location.savedTitle"),
|
||||
t("settings.location.geoPartial"),
|
||||
);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
setLocating(false);
|
||||
notify.error(
|
||||
t("settings.location.errorTitle"),
|
||||
t("settings.location.geoError"),
|
||||
);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const lat = parseCoord(latitude);
|
||||
const lng = parseCoord(longitude);
|
||||
if (lat === false || lng === false) {
|
||||
notify.error(
|
||||
t("settings.location.errorTitle"),
|
||||
t("settings.location.invalidCoords"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveClinicLocation({
|
||||
address: address.trim(),
|
||||
city: city.trim(),
|
||||
country: country.trim(),
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
});
|
||||
notify.success(
|
||||
t("settings.location.savedTitle"),
|
||||
t("settings.location.savedBody"),
|
||||
);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.location.errorTitle"),
|
||||
t("settings.location.error"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
description={t("settings.location.description")}
|
||||
title={t("settings.location.title")}
|
||||
>
|
||||
<SettingsCard className="flex flex-col gap-4 p-5">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t("settings.location.address")}</FieldLabel>
|
||||
<Input
|
||||
disabled={loading}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder={t("settings.location.addressPlaceholder")}
|
||||
value={address}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t("settings.location.city")}</FieldLabel>
|
||||
<Input
|
||||
disabled={loading}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
value={city}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t("settings.location.country")}</FieldLabel>
|
||||
<Input
|
||||
disabled={loading}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
value={country}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t("settings.location.latitude")}</FieldLabel>
|
||||
<Input
|
||||
disabled={loading}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
placeholder="e.g. 2.0469"
|
||||
value={latitude}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<FieldLabel>{t("settings.location.longitude")}</FieldLabel>
|
||||
<Input
|
||||
disabled={loading}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
placeholder="e.g. 45.3182"
|
||||
value={longitude}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.location.coordinatesHint")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className={cn("rounded-lg", whiteButton)}
|
||||
disabled={loading || saving}
|
||||
onClick={save}
|
||||
type="button"
|
||||
>
|
||||
{saving
|
||||
? t("settings.location.saving")
|
||||
: t("settings.location.save")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-lg"
|
||||
disabled={loading || locating}
|
||||
onClick={useMyLocation}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<LocateFixed className="size-4" />
|
||||
{locating
|
||||
? t("settings.location.locating")
|
||||
: t("settings.location.useMyLocation")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,53 @@ import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardFrame,
|
||||
CardFrameAction,
|
||||
CardFrameDescription,
|
||||
CardFrameHeader,
|
||||
CardFramePanel,
|
||||
CardFrameTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// A settings section rendered inside the COSS "frame" surface: a titled header
|
||||
// (with optional description + action) sitting above a padded body. Replaces the
|
||||
// old plain-`div` SettingsCard pattern so every settings panel shares one framed
|
||||
// look. Use `bodyClassName` to control the body layout (spacing/grid).
|
||||
export function SettingsFrame({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<CardFrame className={className}>
|
||||
<CardFrameHeader className="border-b border-border/60">
|
||||
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
|
||||
{description ? (
|
||||
<CardFrameDescription>{description}</CardFrameDescription>
|
||||
) : null}
|
||||
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
|
||||
</CardFrameHeader>
|
||||
<CardFramePanel className={bodyClassName}>{children}</CardFramePanel>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// Back-compat wrapper: existing panels compose with SettingsSection, which now
|
||||
// renders through the COSS frame surface so the whole settings page shares one
|
||||
// framed look. The body keeps vertical spacing between stacked children.
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
@@ -20,21 +64,21 @@ export function SettingsSection({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
<SettingsFrame
|
||||
action={action}
|
||||
bodyClassName="space-y-4"
|
||||
description={description}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</SettingsFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// A card surface used inside settings panels. Rendering a real COSS `Card` (with
|
||||
// `data-slot="card"`) keeps settings cards consistent with the rest of the app
|
||||
// and lets them pick up the frame's card treatment. `Card` is `flex flex-col`,
|
||||
// so row layouts must pass `flex-row` in their className.
|
||||
export function SettingsCard({
|
||||
className,
|
||||
children,
|
||||
@@ -42,11 +86,7 @@ export function SettingsCard({
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <Card className={className}>{children}</Card>;
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
@@ -64,7 +104,7 @@ export function ToggleRow({
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 px-4 py-3.5">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? (
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, QrCode } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import QRCodeSvg from "react-qr-code";
|
||||
|
||||
import {
|
||||
CopyField,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { getPortalLink, portalPairingUri } from "@/lib/portal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Patient Portal section (Settings → Signing): surfaces the clinic's public
|
||||
// portal link so patients can open it, copy it, or scan a QR. The portal lives
|
||||
// at /portal/<org-slug>. The QR encodes a `temetro-portal:` pairing URI (relay
|
||||
// URL + clinic signing key) — the wallet app scans it and talks to this clinic
|
||||
// over the Temetro Network relay, so it works from a real phone (no localhost).
|
||||
export function PatientPortalSection() {
|
||||
const { t } = useTranslation();
|
||||
const { data: activeOrg } = authClient.useActiveOrganization();
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrUri, setQrUri] = useState("");
|
||||
|
||||
const slug = activeOrg?.slug;
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
|
||||
|
||||
// Fetch the relay-based pairing descriptor for the QR (non-secret).
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setQrUri("");
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
getPortalLink(slug)
|
||||
.then((link) => {
|
||||
if (active) setQrUri(portalPairingUri(link));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setQrUri("");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
description={t("settings.portal.description")}
|
||||
title={t("settings.portal.title")}
|
||||
>
|
||||
<SettingsCard className="flex flex-col gap-4 p-5">
|
||||
<CopyField
|
||||
description={t("settings.portal.linkDescription")}
|
||||
label={t("settings.portal.linkLabel")}
|
||||
value={portalUrl || "—"}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className={cn("rounded-lg", whiteButton)}
|
||||
disabled={!portalUrl}
|
||||
onClick={() => window.open(portalUrl, "_blank", "noopener")}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
{t("settings.portal.open")}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-lg"
|
||||
disabled={!qrUri}
|
||||
onClick={() => setQrOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
{t("settings.portal.showQr")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog onOpenChange={setQrOpen} open={qrOpen}>
|
||||
<DialogPopup className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.portal.qrTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.portal.qrDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
|
||||
{qrUri ? (
|
||||
<div className="rounded-2xl bg-white p-4">
|
||||
<QRCodeSvg value={qrUri} size={220} />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="break-all text-center text-sm text-muted-foreground">
|
||||
{portalUrl}
|
||||
</p>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { persistLanguage } from "@/lib/language";
|
||||
import { SPECIALTIES, specialtyLabel } from "@/lib/staff";
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
@@ -60,8 +61,21 @@ const DEFAULT_PREFS: UserPreferences = {
|
||||
"notif.recordsShared": true,
|
||||
clinic: "",
|
||||
contactEmail: "",
|
||||
specialty: "",
|
||||
// Professional links stored as a JSON array string (values are boolean|string).
|
||||
links: "",
|
||||
};
|
||||
|
||||
// Parse the stored `links` preference (a JSON array string) into an array.
|
||||
function parseLinks(value: unknown): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || "[]"));
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfilePanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
@@ -110,13 +124,23 @@ export function ProfilePanel() {
|
||||
const setPref = (key: string, value: boolean | string) =>
|
||||
setPrefs((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const links = parseLinks(prefs.links);
|
||||
const setLinks = (next: string[]) =>
|
||||
setPref("links", JSON.stringify(next));
|
||||
|
||||
const dirty =
|
||||
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveSettings(prefs);
|
||||
// Drop blank link rows before persisting.
|
||||
const cleanedLinks = links.map((l) => l.trim()).filter(Boolean);
|
||||
const toSave: UserPreferences = {
|
||||
...prefs,
|
||||
links: cleanedLinks.length ? JSON.stringify(cleanedLinks) : "",
|
||||
};
|
||||
const saved = await saveSettings(toSave);
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed !== baselineName) {
|
||||
const { error } = await authClient.updateUser({ name: trimmed });
|
||||
@@ -182,13 +206,31 @@ export function ProfilePanel() {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
|
||||
<button
|
||||
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
|
||||
type="button"
|
||||
<Select
|
||||
onValueChange={(value) => setPref("specialty", value ?? "")}
|
||||
value={String(prefs.specialty ?? "")}
|
||||
>
|
||||
{t("settings.profile.selectSpecialty")}
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(value: string) =>
|
||||
value ? (
|
||||
specialtyLabel(t, value)
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("settings.profile.selectSpecialty")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectPopup>
|
||||
{SPECIALTIES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{t(`settings.careTeam.specialties.${s}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -221,7 +263,43 @@ export function ProfilePanel() {
|
||||
{t("settings.profile.professionalLinksHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="rounded-lg" size="sm" variant="outline">
|
||||
{links.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{links.map((link, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
|
||||
<div className="flex items-center gap-2" key={index}>
|
||||
<Input
|
||||
inputMode="url"
|
||||
onChange={(event) =>
|
||||
setLinks(
|
||||
links.map((value, i) =>
|
||||
i === index ? event.target.value : value,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={t("settings.profile.linkPlaceholder")}
|
||||
value={link}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("settings.profile.removeLink")}
|
||||
onClick={() =>
|
||||
setLinks(links.filter((_, i) => i !== index))
|
||||
}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className="rounded-lg"
|
||||
onClick={() => setLinks([...links, ""])}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("settings.profile.addLink")}
|
||||
</Button>
|
||||
@@ -293,28 +371,11 @@ export function ProfilePanel() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Future features — intentionally not wired to persistence yet. */}
|
||||
<SettingsSection
|
||||
description={t("settings.profile.featuresDescription")}
|
||||
title={t("settings.profile.features")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
description={t("settings.profile.notif.patientStorageDesc")}
|
||||
title={t("settings.profile.notif.patientStorage")}
|
||||
/>
|
||||
<ToggleRow
|
||||
description={t("settings.profile.notif.requireSignedDesc")}
|
||||
title={t("settings.profile.notif.requireSigned")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.profile.dangerZoneDescription")}
|
||||
title={t("settings.profile.dangerZone")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-4">
|
||||
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.profile.deleteAccount")}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { dirFor } from "@/lib/i18n/config";
|
||||
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
@@ -27,7 +28,10 @@ import { useCallInvites } from "@/components/meetings/use-call-invites";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
// Anchor the sidebar to the right for RTL locales (Arabic) so the whole shell
|
||||
// mirrors instead of leaving the fixed sidebar pinned physically left.
|
||||
const side = dirFor(i18n.language) === "rtl" ? "right" : "left";
|
||||
const role = useActiveRole();
|
||||
const { allowed: aiAllowed } = useAiAccess();
|
||||
const isCollapsed = state === "collapsed";
|
||||
@@ -54,7 +58,7 @@ export function DashboardSidebar() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<Sidebar variant="inset" collapsible="icon" side={side}>
|
||||
<SidebarHeader
|
||||
className={cn(
|
||||
"flex md:pt-3.5",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Building2,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Plus,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { persistLanguage } from "@/lib/language";
|
||||
import { useActiveRole } from "@/lib/roles";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
@@ -82,7 +85,13 @@ function GitHubIcon({ className }: { className?: string }) {
|
||||
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
|
||||
// popup reveals the active clinic's details on hover.
|
||||
export function NavUser() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const activeLang = i18n.resolvedLanguage ?? i18n.language;
|
||||
const changeLanguage = (lng: string) => {
|
||||
if (lng === activeLang) return;
|
||||
void i18n.changeLanguage(lng);
|
||||
void persistLanguage(lng);
|
||||
};
|
||||
const { isMobile, state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const router = useRouter();
|
||||
@@ -197,6 +206,29 @@ export function NavUser() {
|
||||
</MenuShortcut>
|
||||
</MenuItem>
|
||||
|
||||
{/* Language: quick switch (also available in Settings). Applies
|
||||
immediately and roams via the backend preferences. */}
|
||||
<MenuSub>
|
||||
<MenuSubTrigger>
|
||||
<Languages />
|
||||
<span className="truncate">{t("userMenu.language")}</span>
|
||||
</MenuSubTrigger>
|
||||
<MenuSubPopup className="min-w-44" sideOffset={8}>
|
||||
{supportedLanguages.map((lng) => (
|
||||
<MenuItem
|
||||
className="gap-2"
|
||||
key={lng}
|
||||
onClick={() => changeLanguage(lng)}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{t(`settings.profile.language.${lng}`)}
|
||||
</span>
|
||||
{lng === activeLang && <Check className="size-4" />}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuSubPopup>
|
||||
</MenuSub>
|
||||
|
||||
{/* Command palette: hint + shortcut, sits below Theme. */}
|
||||
<MenuItem onClick={openCommand}>
|
||||
<Search />
|
||||
|
||||
@@ -136,6 +136,26 @@ export function CardFrameFooter({
|
||||
});
|
||||
}
|
||||
|
||||
// The padded body of a frame: content that sits between the frame header and
|
||||
// footer. Mirrors the other CardFrame* subcomponents (useRender + data-slot) so
|
||||
// a frame can be composed entirely from primitives instead of a raw <div>.
|
||||
export function CardFramePanel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">): React.ReactElement {
|
||||
const defaultProps = {
|
||||
className: cn("p-5", className),
|
||||
"data-slot": "card-frame-panel",
|
||||
};
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
render,
|
||||
});
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
render,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: CheckboxPrimitive.Root.Props): React.ReactElement {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="absolute -inset-px flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
|
||||
data-slot="checkbox-indicator"
|
||||
render={(
|
||||
props: React.ComponentProps<"span">,
|
||||
state: CheckboxPrimitive.Indicator.State,
|
||||
) => (
|
||||
<span {...props}>
|
||||
{state.indeterminate ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12h13.496" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { CheckboxPrimitive };
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { Matcher } from "react-day-picker";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverPopup,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Parse a `YYYY-MM-DD` string as a LOCAL date. `new Date("YYYY-MM-DD")` parses
|
||||
// as UTC and can shift the day across timezones, so build it from parts.
|
||||
function parseISODate(value?: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
if (!y || !m || !d) return undefined;
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
function toISODate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export interface DatePickerProps {
|
||||
/** Selected date as `YYYY-MM-DD` (matches native date-input semantics). */
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Earliest selectable date, `YYYY-MM-DD`. */
|
||||
min?: string;
|
||||
/** Latest selectable date, `YYYY-MM-DD`. */
|
||||
max?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// A COSS date picker: an outline Button trigger opening a Popover with the COSS
|
||||
// Calendar. Drop-in replacement for `<input type="date">`, keeping the same
|
||||
// string value shape so callers don't change their state handling.
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
placeholder,
|
||||
id,
|
||||
className,
|
||||
disabled,
|
||||
}: DatePickerProps): React.ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = parseISODate(value);
|
||||
const minDate = parseISODate(min);
|
||||
const maxDate = parseISODate(max);
|
||||
|
||||
const disabledMatchers: Matcher[] = [
|
||||
...(minDate ? [{ before: minDate }] : []),
|
||||
...(maxDate ? [{ after: maxDate }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected ? selected.toLocaleDateString() : (placeholder ?? "")}
|
||||
<CalendarIcon className="opacity-80" />
|
||||
</PopoverTrigger>
|
||||
<PopoverPopup align="start" className="w-auto">
|
||||
<Calendar
|
||||
autoFocus
|
||||
defaultMonth={selected ?? minDate}
|
||||
disabled={disabledMatchers.length ? disabledMatchers : undefined}
|
||||
mode="single"
|
||||
onSelect={(date?: Date) => {
|
||||
onChange(date ? toISODate(date) : "");
|
||||
if (date) setOpen(false);
|
||||
}}
|
||||
selected={selected}
|
||||
/>
|
||||
</PopoverPopup>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -596,7 +596,7 @@ export function SidebarMenuAction({
|
||||
}): React.ReactElement {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
|
||||
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Types
|
||||
type StepperContextValue = {
|
||||
activeStep: number;
|
||||
setActiveStep: (step: number) => void;
|
||||
orientation: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type StepItemContextValue = {
|
||||
step: number;
|
||||
state: StepState;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type StepState = "active" | "completed" | "inactive" | "loading";
|
||||
|
||||
// Contexts
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const useStepper = () => {
|
||||
const context = useContext(StepperContext);
|
||||
if (!context) {
|
||||
throw new Error("useStepper must be used within a Stepper");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const useStepItem = () => {
|
||||
const context = useContext(StepItemContext);
|
||||
if (!context) {
|
||||
throw new Error("useStepItem must be used within a StepperItem");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Components
|
||||
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 0,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "horizontal",
|
||||
className,
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setInternalStep] = React.useState(defaultValue);
|
||||
|
||||
const setActiveStep = React.useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step);
|
||||
}
|
||||
onValueChange?.(step);
|
||||
},
|
||||
[value, onValueChange],
|
||||
);
|
||||
|
||||
const currentStep = value ?? activeStep;
|
||||
|
||||
return (
|
||||
<StepperContext.Provider
|
||||
value={{
|
||||
activeStep: currentStep,
|
||||
orientation,
|
||||
setActiveStep,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
{...props}
|
||||
/>
|
||||
</StepperContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperItem
|
||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number;
|
||||
completed?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function StepperItem({
|
||||
step,
|
||||
completed = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperItemProps) {
|
||||
const { activeStep } = useStepper();
|
||||
|
||||
const state: StepState =
|
||||
completed || step < activeStep
|
||||
? "completed"
|
||||
: activeStep === step
|
||||
? "active"
|
||||
: "inactive";
|
||||
|
||||
const isLoading = loading && step === activeStep;
|
||||
|
||||
return (
|
||||
<StepItemContext.Provider
|
||||
value={{ isDisabled: disabled, isLoading, state, step }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-item"
|
||||
data-state={state}
|
||||
{...(isLoading ? { "data-loading": true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperTrigger
|
||||
interface StepperTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function StepperTrigger({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperTriggerProps) {
|
||||
const { setActiveStep } = useStepper();
|
||||
const { step, isDisabled } = useStepItem();
|
||||
|
||||
if (asChild) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
return (
|
||||
<Comp className={className} data-slot="stepper-trigger">
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-trigger"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setActiveStep(step)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperIndicator
|
||||
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function StepperIndicator({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperIndicatorProps) {
|
||||
const { state, step, isLoading } = useStepItem();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
{...props}
|
||||
>
|
||||
{asChild ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
|
||||
{step}
|
||||
</span>
|
||||
<CheckIcon
|
||||
aria-hidden="true"
|
||||
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
|
||||
size={16}
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="absolute transition-all">
|
||||
<LoaderCircleIcon
|
||||
aria-hidden="true"
|
||||
className="animate-spin"
|
||||
size={14}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperTitle
|
||||
function StepperTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-medium text-sm", className)}
|
||||
data-slot="stepper-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperDescription
|
||||
function StepperDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="stepper-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperSeparator
|
||||
function StepperSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"m-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Stepper,
|
||||
StepperDescription,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
};
|
||||
@@ -19,7 +19,7 @@ export function Switch({
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
|
||||
"pointer-events-none block aspect-square h-full ltr:origin-left rtl:origin-right in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] ltr:data-checked:translate-x-[calc(var(--thumb-size)-4px)] rtl:data-checked:-translate-x-[calc(var(--thumb-size)-4px)]",
|
||||
)}
|
||||
data-slot="switch-thumb"
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user