mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c326e9f794 | |||
| b299501ab2 | |||
| 17209a2cf1 | |||
| bd8fdfadda | |||
| 7846dacd42 | |||
| 2c239fbd27 | |||
| bffed5525d | |||
| 591b2f9170 | |||
| b557139752 | |||
| 2d7df1bec0 | |||
| feffce6cbf | |||
| 35f07c508d | |||
| 143ffc39f1 | |||
| b33561d7e1 | |||
| 947623a691 | |||
| dd64d689c8 | |||
| 1c5e71eb39 | |||
| fb9fa299c9 | |||
| ab17978b5c |
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -7,6 +7,190 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -31,9 +31,12 @@ repository (published as `temetro`).
|
||||
> 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`.
|
||||
>
|
||||
> **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. The AI chat is still **mock replies**. Email verification is wired but currently **not
|
||||
> enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
> shares. Email verification is wired but currently **not enforced** at sign-in (see
|
||||
> `backend/CLAUDE.md`).
|
||||
|
||||
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "patients" ADD COLUMN "wallet_number" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,13 @@
|
||||
"when": 1783363217049,
|
||||
"tag": "0034_chunky_blacklash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1783530491321,
|
||||
"tag": "0035_slippery_retro_girl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
@@ -64,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",
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,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}`);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
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";
|
||||
@@ -201,6 +203,34 @@ function registerHubHandlers(orgId: string, hub: Socket): void {
|
||||
},
|
||||
);
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
+8
-6
@@ -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).
|
||||
|
||||
@@ -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 (sync.linked) {
|
||||
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>
|
||||
|
||||
@@ -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");
|
||||
@@ -378,6 +396,10 @@ export function PatientFormDialog({
|
||||
t("patientForm.updatedTitle"),
|
||||
t("patientForm.updatedBody", { name: saved.name }),
|
||||
);
|
||||
if (sync.linked) {
|
||||
setStep("wallet");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
onCreated?.(saved.fileNumber);
|
||||
notify.success(
|
||||
@@ -400,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
|
||||
@@ -419,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}
|
||||
@@ -769,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 (sync.linked) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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 (sync.linked) {
|
||||
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 (sync.linked) {
|
||||
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">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { ClinicLocationSection } from "@/components/settings/settings-location";
|
||||
import { PatientPortalSection } from "@/components/settings/settings-portal";
|
||||
import {
|
||||
getNetworkEnabled,
|
||||
getSigningKey,
|
||||
@@ -206,6 +207,8 @@ export function SigningPanel() {
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<PatientPortalSection />
|
||||
|
||||
<ClinicLocationSection />
|
||||
|
||||
<SettingsSection
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LocateFixed } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} 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
|
||||
@@ -28,7 +30,7 @@ function parseCoord(value: string): number | null | false {
|
||||
// Persists the clinic's address + optional map coordinates so the wallet app can
|
||||
// display the clinic location later.
|
||||
export function ClinicLocationSection() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
@@ -36,6 +38,7 @@ export function ClinicLocationSection() {
|
||||
const [longitude, setLongitude] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -60,6 +63,49 @@ export function ClinicLocationSection() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -151,7 +197,7 @@ export function ClinicLocationSection() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.location.coordinatesHint")}
|
||||
</p>
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className={cn("rounded-lg", whiteButton)}
|
||||
disabled={loading || saving}
|
||||
@@ -162,6 +208,18 @@ export function ClinicLocationSection() {
|
||||
? 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>
|
||||
|
||||
@@ -6,8 +6,51 @@ import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CardFrame,
|
||||
CardFrameAction,
|
||||
CardFrameDescription,
|
||||
CardFrameHeader,
|
||||
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>
|
||||
<div className={cn("p-5", bodyClassName)}>{children}</div>
|
||||
</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,18 +63,14 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,23 +371,6 @@ 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")}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TableVariant = "default" | "card";
|
||||
|
||||
export type TableProps = React.ComponentProps<"table"> & {
|
||||
variant?: TableVariant;
|
||||
render?: useRender.ComponentProps<"div">["render"];
|
||||
};
|
||||
|
||||
export function Table({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: TableProps): React.ReactElement {
|
||||
const defaultProps = {
|
||||
children: (
|
||||
<table
|
||||
className={cn(
|
||||
"w-full caption-bottom in-data-[variant=card]:border-separate in-data-[variant=card]:border-spacing-0 text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
className: "relative w-full overflow-x-auto",
|
||||
"data-slot": "table-container",
|
||||
"data-variant": variant,
|
||||
};
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(defaultProps, {}),
|
||||
render,
|
||||
});
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"thead">): React.ReactElement {
|
||||
return (
|
||||
<thead
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
data-slot="table-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tbody">): React.ReactElement {
|
||||
return (
|
||||
<tbody
|
||||
className={cn(
|
||||
"relative in-data-[variant=card]:rounded-xl in-data-[variant=card]:shadow-xs/5 before:pointer-events-none before:absolute before:inset-px not-in-data-[variant=card]:before:hidden before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/8%)] [&_tr:last-child]:border-0 in-data-[variant=card]:*:[tr]:border-0 in-data-[variant=card]:*:[tr]:*:[td]:border-b in-data-[variant=card]:*:[tr]:*:[td]:bg-card in-data-[variant=card]:*:[tr]:first:*:[td]:first:rounded-ss-xl in-data-[variant=card]:*:[tr]:*:[td]:first:border-s in-data-[variant=card]:*:[tr]:first:*:[td]:border-t in-data-[variant=card]:*:[tr]:last:*:[td]:last:rounded-ee-xl in-data-[variant=card]:*:[tr]:*:[td]:last:border-e in-data-[variant=card]:*:[tr]:first:*:[td]:last:rounded-se-xl in-data-[variant=card]:*:[tr]:last:*:[td]:first:rounded-es-xl in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_4%)] dark:in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_4%)] dark:in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-body"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tfoot">): React.ReactElement {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn(
|
||||
"border-t in-data-[variant=card]:border-none bg-transparent not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] font-medium dark:not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)] [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tr">): React.ReactElement {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"relative border-b not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-black)_2%)] not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-black)_4%)] dark:not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-white)_4%)] dark:not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"th">): React.ReactElement {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-10 whitespace-nowrap px-2.5 text-left align-middle font-medium text-muted-foreground leading-none has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-head"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"td">): React.ReactElement {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"whitespace-nowrap bg-clip-padding p-2.5 in-data-[slot=table-footer]:py-3.5 align-middle leading-none in-data-[variant=card]:first:ps-[calc(--spacing(2.5)-1px)] in-data-[variant=card]:last:pe-[calc(--spacing(2.5)-1px)] has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">): React.ReactElement {
|
||||
return (
|
||||
<caption
|
||||
className={cn(
|
||||
"in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number;
|
||||
setActiveStep: (step: number) => void;
|
||||
};
|
||||
|
||||
// Context
|
||||
const TimelineContext = React.createContext<TimelineContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = React.useContext(TimelineContext);
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
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 (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
data-slot="timeline"
|
||||
{...props}
|
||||
/>
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="timeline-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function TimelineDate({
|
||||
asChild = false,
|
||||
className,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const Comp = asChild ? Slot.Root : "time";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className,
|
||||
)}
|
||||
data-slot="timeline-date"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn(className)} data-slot="timeline-header" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className,
|
||||
)}
|
||||
data-slot="timeline-indicator"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number;
|
||||
}
|
||||
|
||||
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-12 has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary",
|
||||
className,
|
||||
)}
|
||||
data-completed={step <= activeStep || undefined}
|
||||
data-slot="timeline-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className,
|
||||
)}
|
||||
data-slot="timeline-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-medium text-sm", className)}
|
||||
data-slot="timeline-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
import { ArrowUpCircle, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getVersionInfo } from "@/lib/version";
|
||||
|
||||
const DISMISS_KEY = "temetro:update-dismissed";
|
||||
@@ -39,28 +41,36 @@ export function UpdateBanner() {
|
||||
setLatest(null);
|
||||
};
|
||||
|
||||
// Pinned to the physical bottom-right in both LTR and RTL. The user wants it
|
||||
// in the bottom-right corner in Arabic too, so we use physical `right`/`bottom`
|
||||
// rather than the logical `end` (which would flip to the left under RTL).
|
||||
return (
|
||||
<div className="fixed end-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm text-foreground">
|
||||
<div className="fixed right-4 bottom-4 z-50 w-full max-w-sm" dir="auto">
|
||||
<Alert className="bg-card shadow-lg" variant="warning">
|
||||
<ArrowUpCircle />
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("settings.version.banner", { version: latest })}
|
||||
</p>
|
||||
<Link
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
href="/settings?tab=version"
|
||||
onClick={dismiss}
|
||||
>
|
||||
{t("settings.version.bannerUpdate")}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={dismiss}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button
|
||||
render={
|
||||
<Link href="/settings?tab=version" onClick={dismiss} />
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.version.bannerUpdate")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
onClick={dismiss}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import {
|
||||
getWalletLink,
|
||||
getWalletUpdate,
|
||||
pushWalletUpdate,
|
||||
type WalletUpdate,
|
||||
} from "@/lib/wallet-updates";
|
||||
|
||||
export type WalletSyncState =
|
||||
| "idle"
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "denied"
|
||||
| "error";
|
||||
|
||||
// Shared wallet-sync logic for create/edit dialogs. It resolves whether the
|
||||
// selected patient has a linked wallet, then (after the primary save) can push
|
||||
// the change to their phone and poll until they approve/deny it — the same
|
||||
// mechanism as the standalone WalletPushDialog, lifted out for reuse.
|
||||
export function useWalletSync(fileNumber: string | null | undefined) {
|
||||
const [linked, setLinked] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [state, setState] = useState<WalletSyncState>("idle");
|
||||
const [update, setUpdate] = useState<WalletUpdate | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resolve link status whenever the chosen patient changes. A 404 simply means
|
||||
// "not wallet-backed", so failures collapse to `linked = false`.
|
||||
useEffect(() => {
|
||||
if (!fileNumber) {
|
||||
setLinked(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setChecking(true);
|
||||
getWalletLink(fileNumber)
|
||||
.then(() => {
|
||||
if (active) setLinked(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLinked(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fileNumber]);
|
||||
|
||||
// Poll the pushed update until the patient approves/denies it.
|
||||
useEffect(() => {
|
||||
if (state !== "pending" || !update || update.resolvedAt) return;
|
||||
let active = true;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const fresh = await getWalletUpdate(update.id);
|
||||
if (!active) return;
|
||||
setUpdate(fresh);
|
||||
if (fresh.status === "approved") setState("approved");
|
||||
else if (fresh.status === "denied") setState("denied");
|
||||
if (fresh.resolvedAt) clearInterval(timer);
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [state, update]);
|
||||
|
||||
const push = useCallback(
|
||||
async (changes: string[]) => {
|
||||
if (!fileNumber) return;
|
||||
setError(null);
|
||||
setState("pending");
|
||||
try {
|
||||
const created = await pushWalletUpdate({ fileNumber, changes });
|
||||
setUpdate(created);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "generic");
|
||||
setState("error");
|
||||
}
|
||||
},
|
||||
[fileNumber],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState("idle");
|
||||
setUpdate(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { linked, checking, state, update, error, push, reset };
|
||||
}
|
||||
|
||||
export type UseWalletSync = ReturnType<typeof useWalletSync>;
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Send, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter, DialogPanel } from "@/components/ui/dialog";
|
||||
import {
|
||||
Stepper,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
} from "@/components/ui/stepper";
|
||||
|
||||
import type { UseWalletSync } from "./use-wallet-sync";
|
||||
|
||||
// Two-step header shown at the top of a dialog when the selected patient has a
|
||||
// linked wallet: "Details" → "Sync to wallet". Centered so the numbered
|
||||
// indicators sit inline with their labels.
|
||||
export function DialogStepper({ step }: { step: "form" | "wallet" }) {
|
||||
const { t } = useTranslation();
|
||||
const steps = [
|
||||
{ value: 1, label: t("walletSync.step1") },
|
||||
{ value: 2, label: t("walletSync.step2") },
|
||||
];
|
||||
const activeValue = step === "form" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<Stepper className="mx-auto mt-3 max-w-sm" value={activeValue}>
|
||||
{steps.map(({ value, label }, i) => (
|
||||
<StepperItem className="not-last:flex-1" key={value} step={value}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<StepperIndicator />
|
||||
<StepperTitle className="whitespace-nowrap text-xs">
|
||||
{label}
|
||||
</StepperTitle>
|
||||
</span>
|
||||
{i < steps.length - 1 && <StepperSeparator className="mx-3" />}
|
||||
</StepperItem>
|
||||
))}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 body + footer: offer to push the just-saved change to the patient's
|
||||
// wallet, then show the approval status. Rendered in place of the form's
|
||||
// DialogPanel/DialogFooter, so it returns them as a fragment.
|
||||
export function WalletSyncStep({
|
||||
patientName,
|
||||
summary,
|
||||
sync,
|
||||
onDone,
|
||||
}: {
|
||||
patientName: string;
|
||||
summary: string;
|
||||
sync: UseWalletSync;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { state, update, error, push } = sync;
|
||||
const status = update?.status ?? "pending";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
||||
{state === "idle" || state === "error" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{t("walletSync.prompt", { name: patientName })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("walletSync.promptBody")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("walletSync.changesLabel")}
|
||||
</span>
|
||||
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error === "generic" ? t("walletSync.errors.generic") : error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
{status === "approved" ? (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-success/15 text-success">
|
||||
<Check className="size-6" />
|
||||
</div>
|
||||
) : status === "denied" ? (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/15 text-destructive">
|
||||
<X className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{t(`walletPush.status.${status}.title`)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`walletPush.status.${status}.body`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
{state === "idle" || state === "error" ? (
|
||||
<>
|
||||
<Button onClick={onDone} type="button" variant="outline">
|
||||
{t("walletSync.skip")}
|
||||
</Button>
|
||||
<Button onClick={() => push([summary])} type="button">
|
||||
<Send className="size-4" />
|
||||
{t("walletSync.send")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onDone} type="button">
|
||||
{t("walletSync.done")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import type { Effort } from "@/lib/ai-models";
|
||||
// saved to /api/ai/config. Provider API keys are write-only: they are never
|
||||
// returned, only `apiKeySet` reports which providers have a stored key.
|
||||
|
||||
export type AiMode = "api" | "local";
|
||||
// "auto" auto-picks a cloud provider when a key is set, else falls back to local
|
||||
// Ollama; "off" disables the assistant entirely.
|
||||
export type AiMode = "api" | "local" | "auto" | "off";
|
||||
export type ApiProvider = "openai" | "anthropic" | "gemini";
|
||||
export type VeilLevel = "off" | "names" | "full";
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Reverse geocoding via OpenStreetMap Nominatim. Used by the clinic-location
|
||||
// settings so "Use my current location" can fill the address/city/country
|
||||
// fields, not just the raw coordinates.
|
||||
//
|
||||
// Nominatim's usage policy asks for a descriptive User-Agent/Referer and low
|
||||
// request volumes — this is only hit on an explicit button click, so occasional
|
||||
// lookups are well within the acceptable-use limits.
|
||||
|
||||
export interface ReverseGeocodeResult {
|
||||
address: string;
|
||||
city: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
// Reverse-geocode a coordinate into a best-effort street address, city, and
|
||||
// country. Resolves to `null` on any failure (network, rate limit, no match) so
|
||||
// the caller can silently fall back to coordinates-only.
|
||||
export async function reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
language?: string,
|
||||
): Promise<ReverseGeocodeResult | null> {
|
||||
try {
|
||||
const url = new URL("https://nominatim.openstreetmap.org/reverse");
|
||||
url.searchParams.set("format", "jsonv2");
|
||||
url.searchParams.set("lat", String(latitude));
|
||||
url.searchParams.set("lon", String(longitude));
|
||||
url.searchParams.set("zoom", "18");
|
||||
url.searchParams.set("addressdetails", "1");
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
// Nominatim requires an identifying header; browsers forbid setting
|
||||
// User-Agent, so Accept-Language localises the returned names.
|
||||
"Accept-Language": language || "en",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as {
|
||||
address?: Record<string, string>;
|
||||
display_name?: string;
|
||||
};
|
||||
const a = data.address ?? {};
|
||||
|
||||
// Compose a street line from the most specific parts available.
|
||||
const street = [a.house_number, a.road].filter(Boolean).join(" ").trim();
|
||||
const address =
|
||||
street ||
|
||||
a.neighbourhood ||
|
||||
a.suburb ||
|
||||
a.pedestrian ||
|
||||
(data.display_name ? data.display_name.split(",")[0] : "") ||
|
||||
"";
|
||||
|
||||
const city =
|
||||
a.city || a.town || a.village || a.municipality || a.county || a.state || "";
|
||||
const country = a.country || "";
|
||||
|
||||
if (!address && !city && !country) return null;
|
||||
return { address, city, country };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,8 @@
|
||||
"createDialogDescription": "أضف عيادة جديدة وانتقل إليها.",
|
||||
"signOutFailed": "فشل تسجيل الخروج",
|
||||
"tryAgain": "يرجى المحاولة مرة أخرى.",
|
||||
"signedOut": "تم تسجيل الخروج"
|
||||
"signedOut": "تم تسجيل الخروج",
|
||||
"language": "اللغة"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "إنشاء عيادة",
|
||||
@@ -374,7 +375,11 @@
|
||||
"doneTitle": "تم حذف المريض",
|
||||
"failedTitle": "تعذّر حذف المريض",
|
||||
"failedBody": "يرجى المحاولة مرة أخرى."
|
||||
}
|
||||
},
|
||||
"filterLabel": "تصفية",
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"allStatuses": "كل الحالات",
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "المواعيد والجدول",
|
||||
@@ -482,6 +487,7 @@
|
||||
"fileNumber": "ملف رقم {{number}}",
|
||||
"change": "تغيير",
|
||||
"issued": "تاريخ الإصدار",
|
||||
"backdate": "تأريخ رجعي",
|
||||
"due": "تاريخ الاستحقاق",
|
||||
"status": "الحالة",
|
||||
"notes": "ملاحظات",
|
||||
@@ -614,7 +620,8 @@
|
||||
"removeDates": "إزالة",
|
||||
"startDate": "تاريخ البدء",
|
||||
"endDate": "تاريخ الانتهاء",
|
||||
"addDates": "إضافة تاريخ البدء/الانتهاء"
|
||||
"addDates": "إضافة تاريخ البدء/الانتهاء",
|
||||
"selectDate": "اختر التاريخ"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1143,7 +1150,12 @@
|
||||
"title": "اربط نموذج ذكاء اصطناعي للبدء",
|
||||
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
|
||||
"action": "فتح إعدادات الذكاء الاصطناعي",
|
||||
"dismiss": "تجاهل"
|
||||
"dismiss": "تجاهل",
|
||||
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
|
||||
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "بطاقة غير مدعومة"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
|
||||
@@ -1550,7 +1562,8 @@
|
||||
"mild": "خفيف",
|
||||
"moderate": "متوسط",
|
||||
"severe": "شديد"
|
||||
}
|
||||
},
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "تعديل السجل",
|
||||
@@ -1846,8 +1859,6 @@
|
||||
"patientNotificationsDescription": "رسائل تُرسل إلى المرضى بشأن سجلاتهم ونتائجهم والموافقات المعلّقة",
|
||||
"accountNotifications": "إشعارات الحساب",
|
||||
"accountNotificationsDescription": "إشعارات تُرسل إليك بشأن مرضاك وفريق الرعاية",
|
||||
"features": "الميزات",
|
||||
"featuresDescription": "أدر ميزات alpha و beta لحسابك",
|
||||
"dangerZone": "منطقة الخطر",
|
||||
"dangerZoneDescription": "إجراءات لا رجعة فيها لحسابك",
|
||||
"deleteAccount": "حذف الحساب",
|
||||
@@ -1886,12 +1897,10 @@
|
||||
"pendingApprovals": "الموافقات المعلّقة",
|
||||
"pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه",
|
||||
"recordsShared": "سجلات شُوركت معي",
|
||||
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي",
|
||||
"patientStorage": "تخزين مملوك للمريض (beta)",
|
||||
"patientStorageDesc": "اكتب السجلات على جهاز المريض الخاص بدلًا من قاعدة بياناتك",
|
||||
"requireSigned": "طلب سجلات موقّعة",
|
||||
"requireSignedDesc": "طلب توقيع على كل تغيير تجريه على سجل مريض"
|
||||
}
|
||||
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي"
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "إزالة الرابط"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "فريق الرعاية",
|
||||
@@ -2033,7 +2042,12 @@
|
||||
"savedBody": "تم تحديث موقع عيادتك.",
|
||||
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
|
||||
"errorTitle": "تعذّر حفظ الموقع",
|
||||
"error": "يرجى المحاولة مرة أخرى."
|
||||
"error": "يرجى المحاولة مرة أخرى.",
|
||||
"useMyLocation": "استخدام موقعي الحالي",
|
||||
"locating": "جارٍ تحديد الموقع…",
|
||||
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
|
||||
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى.",
|
||||
"geoPartial": "تم إدخال الإحداثيات — الرجاء إضافة تفاصيل العنوان يدويًا."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "مفتاح التوقيع",
|
||||
@@ -2103,6 +2117,13 @@
|
||||
"modeLocal": "نموذج محلي (Ollama)",
|
||||
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
|
||||
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
|
||||
"modeAuto": "تلقائي",
|
||||
"modeOff": "إيقاف",
|
||||
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
|
||||
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
|
||||
"offTitle": "المساعد مُوقف",
|
||||
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
|
||||
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
|
||||
"providerTitle": "المزوّد ومفتاح API",
|
||||
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
|
||||
"provider": "المزوّد",
|
||||
@@ -2145,6 +2166,16 @@
|
||||
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
|
||||
"saveFailedTitle": "تعذّر الحفظ",
|
||||
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"portal": {
|
||||
"title": "بوابة المريض",
|
||||
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
|
||||
"linkLabel": "رابط البوابة",
|
||||
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
|
||||
"open": "فتح البوابة",
|
||||
"showQr": "عرض رمز QR",
|
||||
"qrTitle": "رمز QR لبوابة المريض",
|
||||
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2157,7 +2188,15 @@
|
||||
"bookTitle": "حجز موعد",
|
||||
"bookDesc": "جدول زيارة مع فريق الرعاية الخاص بك.",
|
||||
"resultsTitle": "عرض نتائجي",
|
||||
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة."
|
||||
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة.",
|
||||
"walletTitle": "ربط محفظتي",
|
||||
"walletDesc": "استخدم تطبيق temetro لحجز المواعيد وعرض النتائج بنفسك."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "اربط محفظتك",
|
||||
"subtitle": "امسح هذا الرمز بتطبيق محفظة temetro لربطه بهذه العيادة.",
|
||||
"error": "تعذّر تحميل رمز الربط. الرجاء سؤال مكتب الاستقبال.",
|
||||
"hint": "بعد الربط، يمكنك حجز المواعيد وعرض النتائج من هاتفك."
|
||||
},
|
||||
"field": {
|
||||
"name": "الاسم الكامل",
|
||||
@@ -2198,5 +2237,26 @@
|
||||
"lookupAnother": "بحث عن آخر",
|
||||
"errorGeneric": "تعذّر العثور على سجلك. يرجى التحقّق من بياناتك أو سؤال مكتب الاستقبال."
|
||||
}
|
||||
},
|
||||
"walletSync": {
|
||||
"step1": "التفاصيل",
|
||||
"step2": "المزامنة مع المحفظة",
|
||||
"prompt": "إرسال إلى محفظة {{name}}؟",
|
||||
"promptBody": "يستخدم هذا المريض تطبيق المحفظة. أرسل هذا التغيير لمزامنته مع هاتفه — سيوافق عليه على جهازه.",
|
||||
"changesLabel": "ما الذي تغيّر",
|
||||
"skip": "ليس الآن",
|
||||
"send": "إرسال إلى المحفظة",
|
||||
"done": "تم",
|
||||
"errors": {
|
||||
"generic": "تعذّر إرسال التحديث. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"summary": {
|
||||
"invoiceCreated": "فاتورة جديدة {{number}}",
|
||||
"invoiceUpdated": "تم تحديث الفاتورة {{number}}",
|
||||
"appointment": "موعد جديد في {{date}} الساعة {{time}}",
|
||||
"prescription": "وصفة جديدة: {{drug}}",
|
||||
"demographics": "تم تحديث البيانات الديموغرافية",
|
||||
"note": "ملاحظة سريرية جديدة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.",
|
||||
"signOutFailed": "Abmeldung fehlgeschlagen",
|
||||
"tryAgain": "Bitte versuchen Sie es erneut.",
|
||||
"signedOut": "Abgemeldet"
|
||||
"signedOut": "Abgemeldet",
|
||||
"language": "Sprache"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Klinik erstellen",
|
||||
@@ -362,7 +363,11 @@
|
||||
"doneTitle": "Patient gelöscht",
|
||||
"failedTitle": "Patient konnte nicht gelöscht werden",
|
||||
"failedBody": "Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Nach Status filtern",
|
||||
"allStatuses": "Alle Status",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Termine & Zeitplan",
|
||||
@@ -466,6 +471,7 @@
|
||||
"fileNumber": "Aktennr. {{number}}",
|
||||
"change": "Ändern",
|
||||
"issued": "Ausgestellt",
|
||||
"backdate": "Rückdatieren",
|
||||
"due": "Fälligkeitsdatum",
|
||||
"status": "Status",
|
||||
"notes": "Notizen",
|
||||
@@ -598,7 +604,8 @@
|
||||
"removeDates": "Entfernen",
|
||||
"startDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"addDates": "Start-/Enddatum hinzufügen"
|
||||
"addDates": "Start-/Enddatum hinzufügen",
|
||||
"selectDate": "Datum wählen"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1127,7 +1134,12 @@
|
||||
"title": "Verbinden Sie ein KI-Modell, um zu beginnen",
|
||||
"body": "Es ist noch kein KI-Anbieter eingerichtet. Fügen Sie einen API-Schlüssel hinzu oder verweisen Sie temetro auf ein lokales Ollama-Modell, damit der Assistent antworten kann.",
|
||||
"action": "KI-Einstellungen öffnen",
|
||||
"dismiss": "Ausblenden"
|
||||
"dismiss": "Ausblenden",
|
||||
"offTitle": "Der KI-Assistent ist ausgeschaltet",
|
||||
"offBody": "KI ist unter Einstellungen → KI auf Aus gestellt. Schalten Sie sie ein, um den Assistenten zu nutzen."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Nicht unterstützte Karte"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Fragen Sie etwas oder tippen Sie /patient 10293",
|
||||
@@ -1530,7 +1542,8 @@
|
||||
"mild": "Leicht",
|
||||
"moderate": "Mäßig",
|
||||
"severe": "Schwer"
|
||||
}
|
||||
},
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Datensatz bearbeiten",
|
||||
@@ -1826,8 +1839,6 @@
|
||||
"patientNotificationsDescription": "E-Mails an Patienten über ihre Datensätze, Ergebnisse und ausstehenden Genehmigungen",
|
||||
"accountNotifications": "Kontobenachrichtigungen",
|
||||
"accountNotificationsDescription": "Benachrichtigungen an Sie über Ihre Patienten und das Behandlungsteam",
|
||||
"features": "Funktionen",
|
||||
"featuresDescription": "Verwalten Sie Alpha- und Beta-Funktionen für Ihr Konto",
|
||||
"dangerZone": "Gefahrenzone",
|
||||
"dangerZoneDescription": "Unumkehrbare Aktionen für Ihr Konto",
|
||||
"deleteAccount": "Konto löschen",
|
||||
@@ -1866,12 +1877,10 @@
|
||||
"pendingApprovals": "Ausstehende Genehmigungen",
|
||||
"pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt",
|
||||
"recordsShared": "Mit mir geteilte Datensätze",
|
||||
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt",
|
||||
"patientStorage": "Patienteneigener Speicher (Beta)",
|
||||
"patientStorageDesc": "Schreiben Sie Datensätze auf das eigene Gerät des Patienten statt in Ihre Datenbank",
|
||||
"requireSigned": "Signierte Datensätze erforderlich",
|
||||
"requireSignedDesc": "Erfordern Sie eine Signatur für jede Änderung, die Sie an einer Patientenakte vornehmen"
|
||||
}
|
||||
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt"
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Link entfernen"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Behandlungsteam",
|
||||
@@ -2013,7 +2022,12 @@
|
||||
"savedBody": "Der Standort Ihrer Klinik wurde aktualisiert.",
|
||||
"invalidCoords": "Breiten- und Längengrad müssen Zahlen sein.",
|
||||
"errorTitle": "Standort konnte nicht gespeichert werden",
|
||||
"error": "Bitte versuchen Sie es erneut."
|
||||
"error": "Bitte versuchen Sie es erneut.",
|
||||
"useMyLocation": "Aktuellen Standort verwenden",
|
||||
"locating": "Standort wird ermittelt …",
|
||||
"geoUnsupported": "Standort ist auf diesem Gerät nicht verfügbar.",
|
||||
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen.",
|
||||
"geoPartial": "Koordinaten eingetragen – bitte Adressdetails manuell ergänzen."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Signierschlüssel",
|
||||
@@ -2083,6 +2097,13 @@
|
||||
"modeLocal": "Lokales Modell (Ollama)",
|
||||
"modeApiHint": "Anfragen gehen an Ihren gewählten Anbieter. Patientenkennungen werden von Veil anonymisiert, bevor sie das System verlassen.",
|
||||
"modeLocalHint": "Anfragen laufen gegen ein Modell auf Ihrer eigenen Infrastruktur. Keine Patientendaten verlassen die Klinik.",
|
||||
"modeAuto": "Automatisch",
|
||||
"modeOff": "Aus",
|
||||
"modeAutoHint": "Verwendet einen Cloud-API-Schlüssel, sofern vorhanden, andernfalls Ihr lokales Ollama-Modell.",
|
||||
"modeOffHint": "Der KI-Assistent ist deaktiviert. Es werden keine Anfragen gesendet.",
|
||||
"offTitle": "Assistent aus",
|
||||
"offDescription": "Der KI-Assistent ist für Ihr Konto ausgeschaltet.",
|
||||
"offNote": "Wählen Sie oben Automatisch, Cloud-API-Schlüssel oder Lokales Modell, um den Assistenten zu aktivieren.",
|
||||
"providerTitle": "Anbieter & API-Schlüssel",
|
||||
"providerDescription": "Ihr Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt. Sie können einen Schlüssel pro Anbieter speichern und zwischen ihnen wechseln.",
|
||||
"provider": "Anbieter",
|
||||
@@ -2125,6 +2146,16 @@
|
||||
"savedBody": "Ihre KI-Konfiguration wurde aktualisiert.",
|
||||
"saveFailedTitle": "Speichern fehlgeschlagen",
|
||||
"saveFailedBody": "Das Speichern Ihrer KI-Einstellungen ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Patientenportal",
|
||||
"description": "Teilen Sie das öffentliche Buchungsportal Ihrer Klinik mit Patienten – sie können Ihre Ärzte sehen und Termine buchen.",
|
||||
"linkLabel": "Portal-Link",
|
||||
"linkDescription": "Die öffentliche Seite, die Patienten zum Buchen bei Ihrer Klinik öffnen.",
|
||||
"open": "Portal öffnen",
|
||||
"showQr": "QR-Code anzeigen",
|
||||
"qrTitle": "Patientenportal-QR",
|
||||
"qrDescription": "Patienten scannen dies, um das Portal Ihrer Klinik zu öffnen – oder scannen es in der Temetro-Wallet-App, um zu buchen."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2137,7 +2168,15 @@
|
||||
"bookTitle": "Einen Termin buchen",
|
||||
"bookDesc": "Vereinbaren Sie einen Besuch bei Ihrem Behandlungsteam.",
|
||||
"resultsTitle": "Meine Ergebnisse ansehen",
|
||||
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind."
|
||||
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind.",
|
||||
"walletTitle": "Wallet verknüpfen",
|
||||
"walletDesc": "Nutzen Sie die temetro-App, um selbst zu buchen und Ergebnisse zu sehen."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Wallet verknüpfen",
|
||||
"subtitle": "Scannen Sie diesen Code mit der temetro-Wallet-App, um sie mit dieser Klinik zu verbinden.",
|
||||
"error": "Der Kopplungscode konnte nicht geladen werden. Bitte fragen Sie an der Rezeption.",
|
||||
"hint": "Nach der Verknüpfung können Sie Termine buchen und Ergebnisse auf Ihrem Telefon ansehen."
|
||||
},
|
||||
"field": {
|
||||
"name": "Vollständiger Name",
|
||||
@@ -2178,5 +2217,26 @@
|
||||
"lookupAnother": "Weitere nachschlagen",
|
||||
"errorGeneric": "Ihre Akte konnte nicht gefunden werden. Bitte prüfen Sie Ihre Angaben oder wenden Sie sich an die Rezeption."
|
||||
}
|
||||
},
|
||||
"walletSync": {
|
||||
"step1": "Details",
|
||||
"step2": "Mit Wallet abgleichen",
|
||||
"prompt": "An {{name}}s Wallet senden?",
|
||||
"promptBody": "Diese Person nutzt die Wallet-App. Senden Sie diese Änderung, damit sie auf das Gerät übertragen wird — die Bestätigung erfolgt auf dem Telefon.",
|
||||
"changesLabel": "Was sich geändert hat",
|
||||
"skip": "Jetzt nicht",
|
||||
"send": "An Wallet senden",
|
||||
"done": "Fertig",
|
||||
"errors": {
|
||||
"generic": "Aktualisierung konnte nicht gesendet werden. Bitte erneut versuchen."
|
||||
},
|
||||
"summary": {
|
||||
"invoiceCreated": "Neue Rechnung {{number}}",
|
||||
"invoiceUpdated": "Rechnung {{number}} aktualisiert",
|
||||
"appointment": "Neuer Termin am {{date}} um {{time}}",
|
||||
"prescription": "Neues Rezept: {{drug}}",
|
||||
"demographics": "Stammdaten aktualisiert",
|
||||
"note": "Neue klinische Notiz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Add a new clinic and switch to it.",
|
||||
"signOutFailed": "Sign out failed",
|
||||
"tryAgain": "Please try again.",
|
||||
"signedOut": "Signed out"
|
||||
"signedOut": "Signed out",
|
||||
"language": "Language"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Create clinic",
|
||||
@@ -362,7 +363,11 @@
|
||||
"doneTitle": "Patient deleted",
|
||||
"failedTitle": "Couldn't delete patient",
|
||||
"failedBody": "Please try again."
|
||||
}
|
||||
},
|
||||
"filterLabel": "Filter",
|
||||
"filterStatus": "Filter by status",
|
||||
"allStatuses": "All statuses",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & Schedule",
|
||||
@@ -466,6 +471,7 @@
|
||||
"fileNumber": "File #{{number}}",
|
||||
"change": "Change",
|
||||
"issued": "Issued",
|
||||
"backdate": "Back-date",
|
||||
"due": "Due date",
|
||||
"status": "Status",
|
||||
"notes": "Notes",
|
||||
@@ -598,7 +604,8 @@
|
||||
"removeDates": "Remove",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"addDates": "Add start/end dates"
|
||||
"addDates": "Add start/end dates",
|
||||
"selectDate": "Select date"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1127,7 +1134,12 @@
|
||||
"title": "Connect an AI model to get started",
|
||||
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
|
||||
"action": "Open AI settings",
|
||||
"dismiss": "Dismiss"
|
||||
"dismiss": "Dismiss",
|
||||
"offTitle": "The AI assistant is turned off",
|
||||
"offBody": "AI is set to Off in Settings → AI. Turn it on to use the assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Unsupported card"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Ask anything, or type /patient 10293",
|
||||
@@ -1530,7 +1542,8 @@
|
||||
"mild": "Mild",
|
||||
"moderate": "Moderate",
|
||||
"severe": "Severe"
|
||||
}
|
||||
},
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Edit record",
|
||||
@@ -1826,8 +1839,6 @@
|
||||
"patientNotificationsDescription": "Emails sent to patients about their records, results, and pending approvals",
|
||||
"accountNotifications": "Account notifications",
|
||||
"accountNotificationsDescription": "Notifications sent to you about your patients and the care team",
|
||||
"features": "Features",
|
||||
"featuresDescription": "Manage alpha & beta features for your account",
|
||||
"dangerZone": "Danger Zone",
|
||||
"dangerZoneDescription": "Irreversible actions for your account",
|
||||
"deleteAccount": "Delete account",
|
||||
@@ -1866,12 +1877,10 @@
|
||||
"pendingApprovals": "Pending approvals",
|
||||
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
|
||||
"recordsShared": "Records shared with me",
|
||||
"recordsSharedDesc": "Notify me when a patient shares a record with me",
|
||||
"patientStorage": "Patient-owned storage (beta)",
|
||||
"patientStorageDesc": "Write records to the patient's own device instead of your database",
|
||||
"requireSigned": "Require signed records",
|
||||
"requireSignedDesc": "Require a signature on every change you make to a patient record"
|
||||
}
|
||||
"recordsSharedDesc": "Notify me when a patient shares a record with me"
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Remove link"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Care team",
|
||||
@@ -2013,7 +2022,12 @@
|
||||
"savedBody": "Your clinic location has been updated.",
|
||||
"invalidCoords": "Latitude and longitude must be numbers.",
|
||||
"errorTitle": "Couldn't save location",
|
||||
"error": "Please try again."
|
||||
"error": "Please try again.",
|
||||
"useMyLocation": "Use my current location",
|
||||
"locating": "Locating…",
|
||||
"geoUnsupported": "Location isn't available on this device.",
|
||||
"geoError": "Couldn't get your location. Check permissions and try again.",
|
||||
"geoPartial": "Filled the coordinates — please add the address details manually."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Signing key",
|
||||
@@ -2081,8 +2095,15 @@
|
||||
"mode": "Mode",
|
||||
"modeApi": "Cloud API key",
|
||||
"modeLocal": "Local model (Ollama)",
|
||||
"modeAuto": "Automatic",
|
||||
"modeOff": "Off",
|
||||
"modeApiHint": "Requests go to your chosen provider. Patient identifiers are de-identified by Veil before they leave.",
|
||||
"modeLocalHint": "Requests run against a model on your own infrastructure. No patient data leaves the clinic.",
|
||||
"modeAutoHint": "Uses a cloud API key when one is set, otherwise falls back to your local Ollama model.",
|
||||
"modeOffHint": "The AI assistant is disabled. No requests are sent.",
|
||||
"offTitle": "Assistant off",
|
||||
"offDescription": "The AI assistant is turned off for your account.",
|
||||
"offNote": "Choose Automatic, Cloud API key, or Local model above to enable the assistant.",
|
||||
"providerTitle": "Provider & API key",
|
||||
"providerDescription": "Your key is encrypted at rest and never shown again. You can store a key per provider and switch between them.",
|
||||
"provider": "Provider",
|
||||
@@ -2125,6 +2146,16 @@
|
||||
"savedBody": "Your AI configuration has been updated.",
|
||||
"saveFailedTitle": "Could not save",
|
||||
"saveFailedBody": "Saving your AI settings failed. Please try again."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Patient Portal",
|
||||
"description": "Share your clinic's public booking portal with patients — they can view your doctors and book appointments.",
|
||||
"linkLabel": "Portal link",
|
||||
"linkDescription": "The public page patients open to book with your clinic.",
|
||||
"open": "Open portal",
|
||||
"showQr": "Show QR code",
|
||||
"qrTitle": "Patient Portal QR",
|
||||
"qrDescription": "Patients scan this to open your clinic's portal — or scan it in the Temetro wallet app to book."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2137,7 +2168,15 @@
|
||||
"bookTitle": "Book an appointment",
|
||||
"bookDesc": "Schedule a visit with your care team.",
|
||||
"resultsTitle": "View my results",
|
||||
"resultsDesc": "Check upcoming visits and whether results are ready."
|
||||
"resultsDesc": "Check upcoming visits and whether results are ready.",
|
||||
"walletTitle": "Link my wallet",
|
||||
"walletDesc": "Use the temetro app to book and view results yourself."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Link your wallet",
|
||||
"subtitle": "Scan this code with the temetro wallet app to connect it to this clinic.",
|
||||
"error": "Couldn't load the pairing code. Please ask the front desk.",
|
||||
"hint": "After linking, you can book appointments and view results from your phone."
|
||||
},
|
||||
"field": {
|
||||
"name": "Full name",
|
||||
@@ -2178,5 +2217,26 @@
|
||||
"lookupAnother": "Look up another",
|
||||
"errorGeneric": "Couldn't find your record. Please check your details or ask the front desk."
|
||||
}
|
||||
},
|
||||
"walletSync": {
|
||||
"step1": "Details",
|
||||
"step2": "Sync to wallet",
|
||||
"prompt": "Send to {{name}}'s wallet?",
|
||||
"promptBody": "This patient uses the wallet app. Send this change so it syncs to their phone — they approve it on their device.",
|
||||
"changesLabel": "What changed",
|
||||
"skip": "Not now",
|
||||
"send": "Send to wallet",
|
||||
"done": "Done",
|
||||
"errors": {
|
||||
"generic": "Couldn't send the update. Please try again."
|
||||
},
|
||||
"summary": {
|
||||
"invoiceCreated": "New invoice {{number}}",
|
||||
"invoiceUpdated": "Updated invoice {{number}}",
|
||||
"appointment": "New appointment on {{date}} at {{time}}",
|
||||
"prescription": "New prescription: {{drug}}",
|
||||
"demographics": "Demographics updated",
|
||||
"note": "New clinical note"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.",
|
||||
"signOutFailed": "Échec de la déconnexion",
|
||||
"tryAgain": "Veuillez réessayer.",
|
||||
"signedOut": "Déconnecté"
|
||||
"signedOut": "Déconnecté",
|
||||
"language": "Langue"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Créer une clinique",
|
||||
@@ -362,7 +363,11 @@
|
||||
"doneTitle": "Patient supprimé",
|
||||
"failedTitle": "Impossible de supprimer le patient",
|
||||
"failedBody": "Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"filterLabel": "Filtre",
|
||||
"filterStatus": "Filtrer par statut",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Rendez-vous & Planning",
|
||||
@@ -466,6 +471,7 @@
|
||||
"fileNumber": "Dossier n° {{number}}",
|
||||
"change": "Changer",
|
||||
"issued": "Émise",
|
||||
"backdate": "Antidater",
|
||||
"due": "Date d'échéance",
|
||||
"status": "Statut",
|
||||
"notes": "Notes",
|
||||
@@ -598,7 +604,8 @@
|
||||
"removeDates": "Retirer",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"addDates": "Ajouter les dates de début/fin"
|
||||
"addDates": "Ajouter les dates de début/fin",
|
||||
"selectDate": "Choisir une date"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1127,7 +1134,12 @@
|
||||
"title": "Connectez un modèle d'IA pour commencer",
|
||||
"body": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez une clé API ou pointez temetro vers un modèle Ollama local pour que l'assistant puisse répondre.",
|
||||
"action": "Ouvrir les paramètres d'IA",
|
||||
"dismiss": "Ignorer"
|
||||
"dismiss": "Ignorer",
|
||||
"offTitle": "L'assistant IA est désactivé",
|
||||
"offBody": "L'IA est réglée sur Désactivée dans Paramètres → IA. Activez-la pour utiliser l'assistant."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Carte non prise en charge"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Demandez n'importe quoi, ou tapez /patient 10293",
|
||||
@@ -1530,7 +1542,8 @@
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"severe": "Sévère"
|
||||
}
|
||||
},
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Modifier le dossier",
|
||||
@@ -1826,8 +1839,6 @@
|
||||
"patientNotificationsDescription": "E-mails envoyés aux patients à propos de leurs dossiers, résultats et approbations en attente",
|
||||
"accountNotifications": "Notifications du compte",
|
||||
"accountNotificationsDescription": "Notifications qui vous sont envoyées à propos de vos patients et de l'équipe soignante",
|
||||
"features": "Fonctionnalités",
|
||||
"featuresDescription": "Gérez les fonctionnalités alpha & bêta de votre compte",
|
||||
"dangerZone": "Zone de danger",
|
||||
"dangerZoneDescription": "Actions irréversibles pour votre compte",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
@@ -1866,12 +1877,10 @@
|
||||
"pendingApprovals": "Approbations en attente",
|
||||
"pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente",
|
||||
"recordsShared": "Dossiers partagés avec moi",
|
||||
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi",
|
||||
"patientStorage": "Stockage appartenant au patient (bêta)",
|
||||
"patientStorageDesc": "Écrivez les dossiers sur l'appareil du patient plutôt que dans votre base de données",
|
||||
"requireSigned": "Exiger des dossiers signés",
|
||||
"requireSignedDesc": "Exiger une signature sur chaque modification que vous apportez à un dossier patient"
|
||||
}
|
||||
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi"
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Supprimer le lien"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Équipe soignante",
|
||||
@@ -2013,7 +2022,12 @@
|
||||
"savedBody": "L'emplacement de votre clinique a été mis à jour.",
|
||||
"invalidCoords": "La latitude et la longitude doivent être des nombres.",
|
||||
"errorTitle": "Impossible d'enregistrer l'emplacement",
|
||||
"error": "Veuillez réessayer."
|
||||
"error": "Veuillez réessayer.",
|
||||
"useMyLocation": "Utiliser ma position actuelle",
|
||||
"locating": "Localisation…",
|
||||
"geoUnsupported": "La localisation n'est pas disponible sur cet appareil.",
|
||||
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez.",
|
||||
"geoPartial": "Coordonnées renseignées — veuillez ajouter l'adresse manuellement."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Clé de signature",
|
||||
@@ -2083,6 +2097,13 @@
|
||||
"modeLocal": "Modèle local (Ollama)",
|
||||
"modeApiHint": "Les requêtes vont vers le fournisseur que vous avez choisi. Les identifiants des patients sont dé-identifiés par Veil avant leur départ.",
|
||||
"modeLocalHint": "Les requêtes s'exécutent sur un modèle de votre propre infrastructure. Aucune donnée patient ne quitte la clinique.",
|
||||
"modeAuto": "Automatique",
|
||||
"modeOff": "Désactivé",
|
||||
"modeAutoHint": "Utilise une clé API cloud si elle est définie, sinon revient à votre modèle Ollama local.",
|
||||
"modeOffHint": "L'assistant IA est désactivé. Aucune requête n'est envoyée.",
|
||||
"offTitle": "Assistant désactivé",
|
||||
"offDescription": "L'assistant IA est désactivé pour votre compte.",
|
||||
"offNote": "Choisissez Automatique, Clé API cloud ou Modèle local ci-dessus pour activer l'assistant.",
|
||||
"providerTitle": "Fournisseur & clé API",
|
||||
"providerDescription": "Votre clé est chiffrée au repos et n'est plus jamais affichée. Vous pouvez stocker une clé par fournisseur et basculer entre elles.",
|
||||
"provider": "Fournisseur",
|
||||
@@ -2125,6 +2146,16 @@
|
||||
"savedBody": "Votre configuration d'IA a été mise à jour.",
|
||||
"saveFailedTitle": "Impossible d'enregistrer",
|
||||
"saveFailedBody": "L'enregistrement de vos paramètres d'IA a échoué. Veuillez réessayer."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail patient",
|
||||
"description": "Partagez le portail de réservation public de votre clinique avec les patients — ils peuvent voir vos médecins et prendre rendez-vous.",
|
||||
"linkLabel": "Lien du portail",
|
||||
"linkDescription": "La page publique que les patients ouvrent pour réserver avec votre clinique.",
|
||||
"open": "Ouvrir le portail",
|
||||
"showQr": "Afficher le QR code",
|
||||
"qrTitle": "QR du portail patient",
|
||||
"qrDescription": "Les patients le scannent pour ouvrir le portail de votre clinique — ou le scannent dans l'application Temetro pour réserver."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2137,7 +2168,15 @@
|
||||
"bookTitle": "Prendre un rendez-vous",
|
||||
"bookDesc": "Planifiez une visite avec votre équipe soignante.",
|
||||
"resultsTitle": "Voir mes résultats",
|
||||
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts."
|
||||
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts.",
|
||||
"walletTitle": "Lier mon portefeuille",
|
||||
"walletDesc": "Utilisez l'application temetro pour réserver et voir vos résultats vous-même."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Lier votre portefeuille",
|
||||
"subtitle": "Scannez ce code avec l'application portefeuille temetro pour la connecter à cette clinique.",
|
||||
"error": "Impossible de charger le code d'association. Veuillez demander à l'accueil.",
|
||||
"hint": "Une fois lié, vous pourrez prendre rendez-vous et consulter vos résultats depuis votre téléphone."
|
||||
},
|
||||
"field": {
|
||||
"name": "Nom complet",
|
||||
@@ -2178,5 +2217,26 @@
|
||||
"lookupAnother": "Rechercher un autre",
|
||||
"errorGeneric": "Impossible de trouver votre dossier. Veuillez vérifier vos informations ou demander à l'accueil."
|
||||
}
|
||||
},
|
||||
"walletSync": {
|
||||
"step1": "Détails",
|
||||
"step2": "Synchroniser au portefeuille",
|
||||
"prompt": "Envoyer au portefeuille de {{name}} ?",
|
||||
"promptBody": "Ce patient utilise l'application portefeuille. Envoyez cette modification pour la synchroniser sur son téléphone — il l'approuvera sur son appareil.",
|
||||
"changesLabel": "Ce qui a changé",
|
||||
"skip": "Plus tard",
|
||||
"send": "Envoyer au portefeuille",
|
||||
"done": "Terminé",
|
||||
"errors": {
|
||||
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
|
||||
},
|
||||
"summary": {
|
||||
"invoiceCreated": "Nouvelle facture {{number}}",
|
||||
"invoiceUpdated": "Facture {{number}} mise à jour",
|
||||
"appointment": "Nouveau rendez-vous le {{date}} à {{time}}",
|
||||
"prescription": "Nouvelle ordonnance : {{drug}}",
|
||||
"demographics": "Données démographiques mises à jour",
|
||||
"note": "Nouvelle note clinique"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Ku dar rug cusub oo u beddel.",
|
||||
"signOutFailed": "Ka-bixitaanka waa fashilmay",
|
||||
"tryAgain": "Fadlan isku day mar kale.",
|
||||
"signedOut": "Waa laga baxay"
|
||||
"signedOut": "Waa laga baxay",
|
||||
"language": "Luqadda"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Samee rug",
|
||||
@@ -362,7 +363,11 @@
|
||||
"doneTitle": "Bukaanka waa la tirtiray",
|
||||
"failedTitle": "Bukaanka lama tirtiri karin",
|
||||
"failedBody": "Fadlan isku day mar kale."
|
||||
}
|
||||
},
|
||||
"filterLabel": "Kala sooc",
|
||||
"filterStatus": "Ku kala sooc xaaladda",
|
||||
"allStatuses": "Dhammaan xaaladaha",
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Ballamaha & Jadwalka",
|
||||
@@ -466,6 +471,7 @@
|
||||
"fileNumber": "Faylka #{{number}}",
|
||||
"change": "Beddel",
|
||||
"issued": "La bixiyay",
|
||||
"backdate": "Taariikh hore",
|
||||
"due": "Taariikhda la sugayo",
|
||||
"status": "Xaalad",
|
||||
"notes": "Qoraallo",
|
||||
@@ -598,7 +604,8 @@
|
||||
"removeDates": "Ka saar",
|
||||
"startDate": "Taariikhda bilowga",
|
||||
"endDate": "Taariikhda dhammaadka",
|
||||
"addDates": "Ku dar taariikhaha bilow/dhammaad"
|
||||
"addDates": "Ku dar taariikhaha bilow/dhammaad",
|
||||
"selectDate": "Dooro taariikhda"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1127,7 +1134,12 @@
|
||||
"title": "Ku xir moodeel AI si aad u bilowdo",
|
||||
"body": "Weli bixiye AI lama dejin. Ku dar furaha API ama ku tilmaam temetro moodeel Ollama oo maxalli ah si kaaliyuhu u jawaabo.",
|
||||
"action": "Fur dejinta AI",
|
||||
"dismiss": "Rid"
|
||||
"dismiss": "Rid",
|
||||
"offTitle": "Kaaliyaha AI waa la damiyay",
|
||||
"offBody": "AI waxaa loo dejiyay Damin gudaha Settings → AI. Shid si aad u isticmaasho kaaliyaha."
|
||||
},
|
||||
"card": {
|
||||
"unsupported": "Kaadh aan la taageerin"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Waydii wax kasta, ama qor /patient 10293",
|
||||
@@ -1530,7 +1542,8 @@
|
||||
"mild": "Fudud",
|
||||
"moderate": "Dhexdhexaad",
|
||||
"severe": "Daran"
|
||||
}
|
||||
},
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Wax ka beddel diiwaanka",
|
||||
@@ -1826,8 +1839,6 @@
|
||||
"patientNotificationsDescription": "Iimaylada loo diro bukaannada oo ku saabsan diiwaannadooda, natiijooyinka, iyo ansixinta la sugayo",
|
||||
"accountNotifications": "Ogeysiisyada akoonka",
|
||||
"accountNotificationsDescription": "Ogeysiisyada laguu soo diro oo ku saabsan bukaannadaada iyo kooxda daryeelka",
|
||||
"features": "Astaamaha",
|
||||
"featuresDescription": "Maaree astaamaha alpha & beta ee akoonkaaga",
|
||||
"dangerZone": "Aagga khatarta",
|
||||
"dangerZoneDescription": "Ficillo aan dib loo celin karin oo akoonkaaga",
|
||||
"deleteAccount": "Tirtir akoonka",
|
||||
@@ -1866,12 +1877,10 @@
|
||||
"pendingApprovals": "Ansixinta la sugayo",
|
||||
"pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo",
|
||||
"recordsShared": "Diiwaanno la ila wadaagay",
|
||||
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago",
|
||||
"patientStorage": "Kaydka bukaanka (beta)",
|
||||
"patientStorageDesc": "Ku qor diiwaannada qalabka gaarka ah ee bukaanka halkii xogtaada",
|
||||
"requireSigned": "U baahan diiwaanno la saxiixay",
|
||||
"requireSignedDesc": "U baahan saxiix isbeddel kasta oo aad ku sameyso diiwaanka bukaanka"
|
||||
}
|
||||
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago"
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Ka saar linkiga"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Kooxda daryeelka",
|
||||
@@ -2013,7 +2022,12 @@
|
||||
"savedBody": "Goobta rugtaada waa la cusboonaysiiyay.",
|
||||
"invalidCoords": "Latitude iyo longitude waa inay noqdaan tirooyin.",
|
||||
"errorTitle": "Lama kaydin karin goobta",
|
||||
"error": "Fadlan mar kale isku day."
|
||||
"error": "Fadlan mar kale isku day.",
|
||||
"useMyLocation": "Isticmaal goobtayda hadda",
|
||||
"locating": "Waa la helayaa goobta…",
|
||||
"geoUnsupported": "Goobta laguma heli karo qalabkan.",
|
||||
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale.",
|
||||
"geoPartial": "Isudhigyada waa la buuxiyay — fadlan gacanta ku gali faahfaahinta ciwaanka."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Furaha saxiixa",
|
||||
@@ -2083,6 +2097,13 @@
|
||||
"modeLocal": "Moodeel maxalli ah (Ollama)",
|
||||
"modeApiHint": "Codsiyada waxay u socdaan bixiyahaaga aad dooratay. Aqoonsiyada bukaanka waxaa qariya Veil ka hor inta aysan bixin.",
|
||||
"modeLocalHint": "Codsiyada waxay ku socdaan moodeel kaabayaashaada ah. Xog bukaan kama baxdo rugta.",
|
||||
"modeAuto": "Toos ah",
|
||||
"modeOff": "Damin",
|
||||
"modeAutoHint": "Wuxuu isticmaalaa furaha API-ga daruuraha marka la dejiyo, haddii kale wuxuu ku noqdaa moodelkaaga Ollama ee gudaha.",
|
||||
"modeOffHint": "Kaaliyaha AI waa la damiyay. Wax codsi ah lama dirayo.",
|
||||
"offTitle": "Kaaliyuhu waa damsan yahay",
|
||||
"offDescription": "Kaaliyaha AI waa laga damiyay akoonkaaga.",
|
||||
"offNote": "Dooro Toos ah, Furaha API daruuraha, ama Moodel Gudaha kor si aad u shidid kaaliyaha.",
|
||||
"providerTitle": "Bixiye & furaha API",
|
||||
"providerDescription": "Furahaaga waa la shifraa marka la kaydiyo mana dib loo muujiyo. Waxaad kaydin kartaa fur bixiye kasta oo aad kala beddeli kartaa.",
|
||||
"provider": "Bixiye",
|
||||
@@ -2125,6 +2146,16 @@
|
||||
"savedBody": "Qaabaynta AI-gaaga waa la cusbooneysiiyay.",
|
||||
"saveFailedTitle": "Lama kaydin karin",
|
||||
"saveFailedBody": "Kaydinta dejinta AI waa fashilantay. Fadlan isku day mar kale."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Boggaga Bukaanka",
|
||||
"description": "La wadaag bukaannada boggaga ballanqaadka guud ee rugtaada — waxay arki karaan dhakhtarradaada oo ballan qaadan karaan.",
|
||||
"linkLabel": "Linkiga boggaga",
|
||||
"linkDescription": "Bogga guud ee bukaannadu furaan si ay ballan ugu qaataan rugtaada.",
|
||||
"open": "Fur boggaga",
|
||||
"showQr": "Muuji koodhka QR",
|
||||
"qrTitle": "Koodhka QR ee Boggaga Bukaanka",
|
||||
"qrDescription": "Bukaannadu waxay tan sawiraan si ay u furaan boggaga rugtaada — ama ku sawir abka Temetro wallet si aad ballan u qaadato."
|
||||
}
|
||||
},
|
||||
"portal": {
|
||||
@@ -2137,7 +2168,15 @@
|
||||
"bookTitle": "Ballan qabso",
|
||||
"bookDesc": "Qorshee booqasho kooxdaada daryeelka.",
|
||||
"resultsTitle": "Eeg natiijooyinkayga",
|
||||
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin."
|
||||
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin.",
|
||||
"walletTitle": " Isku xir walletkayga",
|
||||
"walletDesc": "Isticmaal abka temetro si aad adigu u ballansato oo aad u aragto natiijooyinka."
|
||||
},
|
||||
"wallet": {
|
||||
"title": "Isku xir walletkaaga",
|
||||
"subtitle": "Ku sawir koodhkan abka wallet-ka temetro si aad ugu xidho rugtan caafimaad.",
|
||||
"error": "Lama soo rari karin koodhka isku xirka. Fadlan weydii miiska hore.",
|
||||
"hint": "Kadib xirka, waxaad ballan ka samaysan kartaa oo natiijooyinka ka arki kartaa taleefankaaga."
|
||||
},
|
||||
"field": {
|
||||
"name": "Magaca oo dhan",
|
||||
@@ -2178,5 +2217,26 @@
|
||||
"lookupAnother": "Raadi mid kale",
|
||||
"errorGeneric": "Diiwaankaaga lama helin. Fadlan hubi faahfaahintaada ama ka codso miiska hore."
|
||||
}
|
||||
},
|
||||
"walletSync": {
|
||||
"step1": "Faahfaahin",
|
||||
"step2": "La socodsii walletka",
|
||||
"prompt": "Ma u dirtaa walletka {{name}}?",
|
||||
"promptBody": "Bukaankan wuxuu isticmaalaa abka walletka. Dir isbeddelkan si loogu socodsiiyo taleefankooda — waxay ku ansixin doonaan aaladdooda.",
|
||||
"changesLabel": "Wixii isbeddelay",
|
||||
"skip": "Hadda maya",
|
||||
"send": "U dir walletka",
|
||||
"done": "Dhammaystiran",
|
||||
"errors": {
|
||||
"generic": "Cusboonaysiinta lama diri karin. Fadlan mar kale isku day."
|
||||
},
|
||||
"summary": {
|
||||
"invoiceCreated": "Qaansheeg cusub {{number}}",
|
||||
"invoiceUpdated": "Qaansheeg {{number}} la cusboonaysiiyay",
|
||||
"appointment": "Ballan cusub {{date}} saacadda {{time}}",
|
||||
"prescription": "Rijeeto cusub: {{drug}}",
|
||||
"demographics": "Xogta bukaanka la cusboonaysiiyay",
|
||||
"note": "Qoraal caafimaad cusub"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,30 @@ export function getPortalClinic(clinic: string): Promise<PortalClinic> {
|
||||
return portalFetch<PortalClinic>(`/${encodeURIComponent(clinic)}`);
|
||||
}
|
||||
|
||||
// The relay-based pairing descriptor the wallet app scans: the clinic's signing
|
||||
// public key (relay routing id) + the relay URL. Non-secret values.
|
||||
export type PortalLink = {
|
||||
clinicId: string;
|
||||
relay: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function getPortalLink(clinic: string): Promise<PortalLink> {
|
||||
return portalFetch<PortalLink>(`/${encodeURIComponent(clinic)}/link`);
|
||||
}
|
||||
|
||||
// Build the `temetro-portal:` URI the wallet app scans to reach this clinic over
|
||||
// the Temetro Network relay (no localhost API URL — works from a real phone).
|
||||
export function portalPairingUri(link: PortalLink): string {
|
||||
const params = new URLSearchParams({
|
||||
relay: link.relay,
|
||||
clinic: link.clinicId,
|
||||
slug: link.slug,
|
||||
});
|
||||
return `temetro-portal:?${params.toString()}`;
|
||||
}
|
||||
|
||||
export type PortalNewPatient = {
|
||||
name: string;
|
||||
sex?: string;
|
||||
|
||||
Generated
+1248
-116
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -52,6 +52,7 @@
|
||||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
Generated
+797
-101
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.9.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
@@ -49,6 +49,24 @@
|
||||
"skillPath": "better-auth/emailAndPassword/SKILL.md",
|
||||
"computedHash": "747d7f73bd9e2ec5317b8ef88dc70f97df716d00e3d02de4051feebaa48b062d"
|
||||
},
|
||||
"heroui-migration": {
|
||||
"source": "heroui-inc/heroui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/heroui-migration/SKILL.md",
|
||||
"computedHash": "9696b5cf0009120bfe03e00cc94c0e2defc1f5af981fc5bb10b1eeffa53e34cf"
|
||||
},
|
||||
"heroui-native": {
|
||||
"source": "heroui-inc/heroui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/heroui-native/SKILL.md",
|
||||
"computedHash": "d2fc03a13d03678d40799726a144ba72ba1cb08126176ddbf0a0461c901aef29"
|
||||
},
|
||||
"heroui-react": {
|
||||
"source": "heroui-inc/heroui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/heroui-react/SKILL.md",
|
||||
"computedHash": "c9e1bb41fbc3cee782faae08bd465ae05cd685a6ed7cc3392464fdf29b3ab467"
|
||||
},
|
||||
"organization-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
|
||||
Reference in New Issue
Block a user