@fluid-app/fluid-cli-portal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +680 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2230 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/templates/base/index.html +14 -0
- package/templates/base/src/App.tsx +18 -0
- package/templates/base/src/index.css +80 -0
- package/templates/base/src/main.tsx +42 -0
- package/templates/base/src/navigation.config.ts +21 -0
- package/templates/base/src/portal.config.ts +32 -0
- package/templates/base/src/screens/Dashboard.tsx +133 -0
- package/templates/base/src/screens/ExampleForm.tsx +187 -0
- package/templates/base/src/vite-env.d.ts +1 -0
- package/templates/base/tsconfig.json +26 -0
- package/templates/fullstack/.dockerignore +9 -0
- package/templates/fullstack/.env.example +15 -0
- package/templates/fullstack/.github/workflows/ci.yml +47 -0
- package/templates/fullstack/.github/workflows/deploy.yml +54 -0
- package/templates/fullstack/Dockerfile +44 -0
- package/templates/fullstack/README.md.template +176 -0
- package/templates/fullstack/drizzle/0000_initial.sql +7 -0
- package/templates/fullstack/drizzle/meta/0000_snapshot.json +63 -0
- package/templates/fullstack/drizzle/meta/_journal.json +13 -0
- package/templates/fullstack/drizzle.config.ts +13 -0
- package/templates/fullstack/esbuild.config.js +14 -0
- package/templates/fullstack/eslint.config.js +13 -0
- package/templates/fullstack/package.json.template +63 -0
- package/templates/fullstack/src/fluid.config.ts.template +69 -0
- package/templates/fullstack/src/server/db/index.ts +10 -0
- package/templates/fullstack/src/server/db/migrate.ts +12 -0
- package/templates/fullstack/src/server/db/schema.ts +14 -0
- package/templates/fullstack/src/server/entry.ts +59 -0
- package/templates/fullstack/src/server/index.ts +33 -0
- package/templates/fullstack/src/server/routes/index.test.ts +123 -0
- package/templates/fullstack/src/server/routes/index.ts +109 -0
- package/templates/fullstack/src/server/routes/schemas.ts +7 -0
- package/templates/fullstack/src/test/setup.ts +9 -0
- package/templates/fullstack/vite.config.ts +39 -0
- package/templates/fullstack/vitest.config.ts +9 -0
- package/templates/starter/.env.example +1 -0
- package/templates/starter/README.md.template +218 -0
- package/templates/starter/package.json.template +40 -0
- package/templates/starter/src/fluid.config.ts.template +69 -0
- package/templates/starter/vite.config.ts +12 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import app from "../index";
|
|
3
|
+
|
|
4
|
+
describe("API Routes", () => {
|
|
5
|
+
// Health check
|
|
6
|
+
it("GET /api/health returns 200", async () => {
|
|
7
|
+
const res = await app.request("/api/health");
|
|
8
|
+
expect(res.status).toBe(200);
|
|
9
|
+
const body = await res.json();
|
|
10
|
+
expect(body).toEqual({ status: "ok" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// List todos (empty)
|
|
14
|
+
it("GET /api/todos returns empty array", async () => {
|
|
15
|
+
const res = await app.request("/api/todos");
|
|
16
|
+
expect(res.status).toBe(200);
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
expect(body).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Create todo
|
|
22
|
+
it("POST /api/todos creates a todo", async () => {
|
|
23
|
+
const res = await app.request("/api/todos", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ title: "Test todo" }),
|
|
27
|
+
});
|
|
28
|
+
expect(res.status).toBe(201);
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
expect(body.title).toBe("Test todo");
|
|
31
|
+
expect(body.completed).toBe(false);
|
|
32
|
+
expect(body.id).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Create invalid (empty body)
|
|
36
|
+
it("POST /api/todos with empty body returns 400", async () => {
|
|
37
|
+
const res = await app.request("/api/todos", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify({}),
|
|
41
|
+
});
|
|
42
|
+
expect(res.status).toBe(400);
|
|
43
|
+
const body = await res.json();
|
|
44
|
+
expect(body.error).toBe("Validation failed");
|
|
45
|
+
expect(body.details).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Create invalid (empty title)
|
|
49
|
+
it("POST /api/todos with empty title returns 400", async () => {
|
|
50
|
+
const res = await app.request("/api/todos", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ title: "" }),
|
|
54
|
+
});
|
|
55
|
+
expect(res.status).toBe(400);
|
|
56
|
+
const body = await res.json();
|
|
57
|
+
expect(body.error).toBe("Validation failed");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Toggle todo
|
|
61
|
+
it("PATCH /api/todos/:id toggles completion", async () => {
|
|
62
|
+
// Create first
|
|
63
|
+
const createRes = await app.request("/api/todos", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({ title: "Toggle me" }),
|
|
67
|
+
});
|
|
68
|
+
const created = await createRes.json();
|
|
69
|
+
|
|
70
|
+
// Toggle
|
|
71
|
+
const res = await app.request(`/api/todos/${created.id}`, {
|
|
72
|
+
method: "PATCH",
|
|
73
|
+
});
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = await res.json();
|
|
76
|
+
expect(body.completed).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Toggle non-existent
|
|
80
|
+
it("PATCH /api/todos/999 returns 404", async () => {
|
|
81
|
+
const res = await app.request("/api/todos/999", { method: "PATCH" });
|
|
82
|
+
expect(res.status).toBe(404);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Toggle bad ID
|
|
86
|
+
it("PATCH /api/todos/abc returns 400", async () => {
|
|
87
|
+
const res = await app.request("/api/todos/abc", { method: "PATCH" });
|
|
88
|
+
expect(res.status).toBe(400);
|
|
89
|
+
const body = await res.json();
|
|
90
|
+
expect(body.error).toBe("Invalid ID");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Delete todo
|
|
94
|
+
it("DELETE /api/todos/:id deletes a todo", async () => {
|
|
95
|
+
const createRes = await app.request("/api/todos", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ title: "Delete me" }),
|
|
99
|
+
});
|
|
100
|
+
const created = await createRes.json();
|
|
101
|
+
|
|
102
|
+
const res = await app.request(`/api/todos/${created.id}`, {
|
|
103
|
+
method: "DELETE",
|
|
104
|
+
});
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
const body = await res.json();
|
|
107
|
+
expect(body.success).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Delete non-existent
|
|
111
|
+
it("DELETE /api/todos/999 returns 404", async () => {
|
|
112
|
+
const res = await app.request("/api/todos/999", { method: "DELETE" });
|
|
113
|
+
expect(res.status).toBe(404);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Delete bad ID
|
|
117
|
+
it("DELETE /api/todos/abc returns 400", async () => {
|
|
118
|
+
const res = await app.request("/api/todos/abc", { method: "DELETE" });
|
|
119
|
+
expect(res.status).toBe(400);
|
|
120
|
+
const body = await res.json();
|
|
121
|
+
expect(body.error).toBe("Invalid ID");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { eq, sql } from "drizzle-orm";
|
|
3
|
+
import { db } from "../db/index";
|
|
4
|
+
import { todos } from "../db/schema";
|
|
5
|
+
import { createTodoSchema, todoIdSchema } from "./schemas";
|
|
6
|
+
|
|
7
|
+
export const apiRoutes = new Hono();
|
|
8
|
+
|
|
9
|
+
// GET /api/todos — List all todos
|
|
10
|
+
apiRoutes.get("/todos", async (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const allTodos = await db.select().from(todos).all();
|
|
13
|
+
return c.json(allTodos);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(error);
|
|
16
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// POST /api/todos — Create a new todo
|
|
21
|
+
apiRoutes.post("/todos", async (c) => {
|
|
22
|
+
try {
|
|
23
|
+
const body = await c.req.json();
|
|
24
|
+
const result = createTodoSchema.safeParse(body);
|
|
25
|
+
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
return c.json(
|
|
28
|
+
{
|
|
29
|
+
error: "Validation failed",
|
|
30
|
+
details: result.error.flatten().fieldErrors,
|
|
31
|
+
},
|
|
32
|
+
400,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const newTodo = await db
|
|
37
|
+
.insert(todos)
|
|
38
|
+
.values({ title: result.data.title })
|
|
39
|
+
.returning()
|
|
40
|
+
.get();
|
|
41
|
+
return c.json(newTodo, 201);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(error);
|
|
44
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// PATCH /api/todos/:id — Toggle a todo's completion status
|
|
49
|
+
apiRoutes.patch("/todos/:id", async (c) => {
|
|
50
|
+
try {
|
|
51
|
+
const idResult = todoIdSchema.safeParse(c.req.param("id"));
|
|
52
|
+
|
|
53
|
+
if (!idResult.success) {
|
|
54
|
+
return c.json({ error: "Invalid ID" }, 400);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const id = idResult.data;
|
|
58
|
+
const existing = await db
|
|
59
|
+
.select()
|
|
60
|
+
.from(todos)
|
|
61
|
+
.where(eq(todos.id, id))
|
|
62
|
+
.get();
|
|
63
|
+
|
|
64
|
+
if (!existing) {
|
|
65
|
+
return c.json({ error: "Todo not found" }, 404);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const updated = await db
|
|
69
|
+
.update(todos)
|
|
70
|
+
.set({
|
|
71
|
+
completed: !existing.completed,
|
|
72
|
+
updatedAt: sql`(current_timestamp)`,
|
|
73
|
+
})
|
|
74
|
+
.where(eq(todos.id, id))
|
|
75
|
+
.returning()
|
|
76
|
+
.get();
|
|
77
|
+
return c.json(updated);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(error);
|
|
80
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// DELETE /api/todos/:id — Delete a todo
|
|
85
|
+
apiRoutes.delete("/todos/:id", async (c) => {
|
|
86
|
+
try {
|
|
87
|
+
const idResult = todoIdSchema.safeParse(c.req.param("id"));
|
|
88
|
+
|
|
89
|
+
if (!idResult.success) {
|
|
90
|
+
return c.json({ error: "Invalid ID" }, 400);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const id = idResult.data;
|
|
94
|
+
const deleted = await db
|
|
95
|
+
.delete(todos)
|
|
96
|
+
.where(eq(todos.id, id))
|
|
97
|
+
.returning()
|
|
98
|
+
.get();
|
|
99
|
+
|
|
100
|
+
if (!deleted) {
|
|
101
|
+
return c.json({ error: "Todo not found" }, 404);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return c.json({ success: true });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(error);
|
|
107
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
import devServer from "@hono/vite-dev-server";
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [
|
|
8
|
+
react(),
|
|
9
|
+
tailwindcss(),
|
|
10
|
+
devServer({
|
|
11
|
+
entry: "src/server/index.ts",
|
|
12
|
+
// Only send /api/* requests to Hono — let Vite handle everything else
|
|
13
|
+
// (static assets, SPA fallback, HMR, etc.)
|
|
14
|
+
exclude: [/^\/(?!api\/).*/, /^\/$/],
|
|
15
|
+
injectClientScript: false,
|
|
16
|
+
}),
|
|
17
|
+
],
|
|
18
|
+
build: {
|
|
19
|
+
target: "esnext",
|
|
20
|
+
outDir: "dist/public",
|
|
21
|
+
chunkSizeWarningLimit: 600,
|
|
22
|
+
rollupOptions: {
|
|
23
|
+
output: {
|
|
24
|
+
manualChunks(id) {
|
|
25
|
+
if (
|
|
26
|
+
id.includes("node_modules/react-dom") ||
|
|
27
|
+
id.includes("node_modules/react/") ||
|
|
28
|
+
id.includes("node_modules/scheduler")
|
|
29
|
+
) {
|
|
30
|
+
return "vendor";
|
|
31
|
+
}
|
|
32
|
+
if (id.includes("node_modules/@tanstack/react-query")) {
|
|
33
|
+
return "query";
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VITE_API_URL=https://api.fluid.app
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A custom Fluid portal built with [@fluid-app/portal-sdk](https://github.com/fluidcommerce/portal-sdk).
|
|
4
|
+
|
|
5
|
+
This is the **Starter** template - includes a dashboard with multiple widgets plus all core screens.
|
|
6
|
+
|
|
7
|
+
## What's Included
|
|
8
|
+
|
|
9
|
+
### Core Screens (Built-in)
|
|
10
|
+
- **Messages** - Conversation management (MessagingScreen)
|
|
11
|
+
- **Contacts** - Contact directory (ContactsScreen)
|
|
12
|
+
|
|
13
|
+
### Custom Screens
|
|
14
|
+
- **Dashboard** - Overview screen demonstrating:
|
|
15
|
+
- TextWidget - Welcome message and tips
|
|
16
|
+
- ToDoWidget - Task list (uses `useTodos` hook)
|
|
17
|
+
- RecentActivityWidget - Activity feed (uses `useActivities` hook)
|
|
18
|
+
- ChartWidget - Performance visualization
|
|
19
|
+
- CalendarWidget - Event calendar (uses `useCalendarEvents` hook)
|
|
20
|
+
|
|
21
|
+
## Getting Started
|
|
22
|
+
|
|
23
|
+
### Development
|
|
24
|
+
|
|
25
|
+
Start the development server:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm dev
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
|
32
|
+
|
|
33
|
+
### Building for Production
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The output will be in the `dist/` folder.
|
|
40
|
+
|
|
41
|
+
### Preview Production Build
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pnpm preview
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Project Structure
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
src/
|
|
51
|
+
├── main.tsx # App entry with FluidProvider setup
|
|
52
|
+
├── App.tsx # Main app with screen routing
|
|
53
|
+
├── index.css # Global styles (Tailwind CSS)
|
|
54
|
+
├── fluid.config.ts # API and auth configuration
|
|
55
|
+
├── portal.config.ts # Navigation and screen registration
|
|
56
|
+
├── navigation/
|
|
57
|
+
│ └── index.tsx # Sidebar navigation component
|
|
58
|
+
└── screens/
|
|
59
|
+
└── Dashboard.tsx # Dashboard with multiple widgets
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Customization
|
|
63
|
+
|
|
64
|
+
### Modify Navigation
|
|
65
|
+
|
|
66
|
+
Edit `src/portal.config.ts` to add, remove, or reorder navigation items:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
navigation: [
|
|
70
|
+
// Your custom screens
|
|
71
|
+
{ id: "dashboard", label: "Dashboard", icon: "LayoutDashboard", screen: "custom:dashboard" },
|
|
72
|
+
|
|
73
|
+
// Built-in core screens
|
|
74
|
+
{ id: "messaging", label: "Messages", icon: "MessageSquare", screen: "core:messaging" },
|
|
75
|
+
// ...
|
|
76
|
+
],
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Add Custom Screens
|
|
80
|
+
|
|
81
|
+
1. Create a screen component in `src/screens/`:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// src/screens/Orders.tsx
|
|
85
|
+
import { TableWidget, TextWidget } from "@fluid-app/portal-sdk";
|
|
86
|
+
|
|
87
|
+
export function OrdersScreen() {
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-6">
|
|
90
|
+
<TextWidget title="Orders" titleEnabled padding={4} borderRadius="lg" />
|
|
91
|
+
{/* Add more widgets */}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
2. Register in `portal.config.ts`:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { OrdersScreen } from "./screens/Orders";
|
|
101
|
+
|
|
102
|
+
customScreens: {
|
|
103
|
+
dashboard: DashboardScreen,
|
|
104
|
+
orders: OrdersScreen, // Add here
|
|
105
|
+
},
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
3. Add to navigation:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
{ id: "orders", label: "Orders", icon: "ShoppingCart", screen: "custom:orders" },
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Connect to Fluid API
|
|
115
|
+
|
|
116
|
+
Edit `src/fluid.config.ts` to configure your API connection:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
export const fluidConfig: FluidSDKConfig = {
|
|
120
|
+
baseUrl: import.meta.env.VITE_API_URL ?? "https://api.fluid.app",
|
|
121
|
+
getAuthToken: () => getStoredToken() ?? null,
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Available Widgets
|
|
126
|
+
|
|
127
|
+
Import from `@fluid-app/portal-sdk`:
|
|
128
|
+
|
|
129
|
+
| Widget | Description |
|
|
130
|
+
|--------|-------------|
|
|
131
|
+
| `TextWidget` | Text content with optional title |
|
|
132
|
+
| `ChartWidget` | Bar, line, area, pie charts |
|
|
133
|
+
| `TableWidget` | Data tables with columns |
|
|
134
|
+
| `ListWidget` | Lists with icons and actions |
|
|
135
|
+
| `ToDoWidget` | Task list (uses `useTodos`) |
|
|
136
|
+
| `RecentActivityWidget` | Activity feed (uses `useActivities`) |
|
|
137
|
+
| `CalendarWidget` | Calendar view (uses `useCalendarEvents`) |
|
|
138
|
+
| `CatchUpWidget` | Catch-up items |
|
|
139
|
+
| `AlertWidget` | Info, warning, error alerts |
|
|
140
|
+
| `ImageWidget` | Images with sizing options |
|
|
141
|
+
| `VideoWidget` | Embedded video players |
|
|
142
|
+
| `CarouselWidget` | Image/content carousel |
|
|
143
|
+
| `ContainerWidget` | Layout containers |
|
|
144
|
+
| `SpacerWidget` | Vertical spacing |
|
|
145
|
+
|
|
146
|
+
## Building Forms
|
|
147
|
+
|
|
148
|
+
For data-entry screens (order forms, customer intake, settings), we recommend [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) for validation.
|
|
149
|
+
|
|
150
|
+
### Install form dependencies
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pnpm add react-hook-form zod @hookform/resolvers
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Example
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { useForm } from "react-hook-form";
|
|
160
|
+
import { z } from "zod";
|
|
161
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
162
|
+
|
|
163
|
+
const schema = z.object({
|
|
164
|
+
name: z.string().min(1, "Name is required"),
|
|
165
|
+
email: z.string().email("Invalid email"),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
type FormData = z.infer<typeof schema>;
|
|
169
|
+
|
|
170
|
+
export function MyFormScreen() {
|
|
171
|
+
const {
|
|
172
|
+
register,
|
|
173
|
+
handleSubmit,
|
|
174
|
+
formState: { errors, isSubmitting },
|
|
175
|
+
} = useForm<FormData>({
|
|
176
|
+
resolver: zodResolver(schema),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const onSubmit = async (data: FormData) => {
|
|
180
|
+
// Call your API here
|
|
181
|
+
console.log(data);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
186
|
+
<input {...register("name")} placeholder="Name" />
|
|
187
|
+
{errors.name && <p>{errors.name.message}</p>}
|
|
188
|
+
|
|
189
|
+
<input {...register("email")} placeholder="Email" />
|
|
190
|
+
{errors.email && <p>{errors.email.message}</p>}
|
|
191
|
+
|
|
192
|
+
<button type="submit" disabled={isSubmitting}>
|
|
193
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
194
|
+
</button>
|
|
195
|
+
</form>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Starter example
|
|
201
|
+
|
|
202
|
+
A complete form example is included at `src/screens/ExampleForm.tsx`. To enable it:
|
|
203
|
+
|
|
204
|
+
1. Install the form dependencies above
|
|
205
|
+
2. Uncomment the import and nav entry in `src/portal.config.ts`
|
|
206
|
+
|
|
207
|
+
## Styling
|
|
208
|
+
|
|
209
|
+
This project uses [Tailwind CSS v4](https://tailwindcss.com/).
|
|
210
|
+
|
|
211
|
+
Edit `src/index.css` to customize the theme or add global styles.
|
|
212
|
+
|
|
213
|
+
## Learn More
|
|
214
|
+
|
|
215
|
+
- [Fluid Commerce Documentation](https://docs.fluidcommerce.com)
|
|
216
|
+
- [portal-sdk GitHub](https://github.com/fluidcommerce/portal-sdk)
|
|
217
|
+
- [Vite Documentation](https://vite.dev)
|
|
218
|
+
- [React Documentation](https://react.dev)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@fluid-app/portal-sdk": "{{sdkVersion}}",
|
|
14
|
+
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
|
15
|
+
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
|
|
16
|
+
"@fortawesome/react-fontawesome": "^0.2.2",
|
|
17
|
+
"@tanstack/react-query": "^5.90.0",
|
|
18
|
+
"react": "^19.0.0",
|
|
19
|
+
"react-dom": "^19.0.0",
|
|
20
|
+
"react-hook-form": "^7.55.0",
|
|
21
|
+
"@hookform/resolvers": "^4.1.3",
|
|
22
|
+
"zod": "^3.24.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"@types/react-dom": "^19.0.0",
|
|
28
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
29
|
+
"tailwindcss": "^4.0.0",
|
|
30
|
+
"typescript": "^5.6.0",
|
|
31
|
+
"vite": "^6.0.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"packageManager": "pnpm@10.4.1",
|
|
37
|
+
"pnpm": {
|
|
38
|
+
"onlyBuiltDependencies": ["esbuild"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { FluidSDKConfig, FluidAuthConfig } from "@fluid-app/portal-sdk";
|
|
2
|
+
import { getStoredToken } from "@fluid-app/portal-sdk";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fluid SDK Configuration
|
|
6
|
+
*
|
|
7
|
+
* This file contains the configuration for connecting to your Fluid Commerce account.
|
|
8
|
+
* Authentication is handled by FluidAuthProvider, which automatically:
|
|
9
|
+
* - Extracts tokens from URL parameters (passed by parent Fluid app)
|
|
10
|
+
* - Stores tokens in cookies and localStorage
|
|
11
|
+
* - Validates token expiration
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Authentication configuration
|
|
16
|
+
*
|
|
17
|
+
* Pattern: safety-satisfies-over-type-annotation
|
|
18
|
+
* Using `satisfies FluidAuthConfig` validates the object shape against the type
|
|
19
|
+
* WITHOUT widening the inferred types. This means:
|
|
20
|
+
* - TypeScript checks that all required properties are present
|
|
21
|
+
* - TypeScript checks that property types are compatible
|
|
22
|
+
* - The actual inferred types remain as specific as possible
|
|
23
|
+
*
|
|
24
|
+
* Compare to type annotation (`const authConfig: FluidAuthConfig = {...}`):
|
|
25
|
+
* - Type annotation widens types to match the interface exactly
|
|
26
|
+
* - `satisfies` preserves literal types while validating structure
|
|
27
|
+
*/
|
|
28
|
+
export const authConfig = {
|
|
29
|
+
/**
|
|
30
|
+
* Enable dev-mode auth bypass for local development.
|
|
31
|
+
* This lets you run the portal without a real JWT token.
|
|
32
|
+
* Only active in Vite dev mode (pnpm dev) - automatically disabled in production builds.
|
|
33
|
+
*/
|
|
34
|
+
devBypass: true,
|
|
35
|
+
|
|
36
|
+
// Auth failure defaults: SDK automatically redirects to https://auth.fluid.app
|
|
37
|
+
// with a redirect_url back to the current page. To customize:
|
|
38
|
+
// onAuthFailure: () => { window.location.href = "/custom-login"; },
|
|
39
|
+
// authUrl: "https://custom-auth.example.com",
|
|
40
|
+
} satisfies FluidAuthConfig;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* API client configuration
|
|
44
|
+
*
|
|
45
|
+
* Pattern: safety-satisfies-over-type-annotation
|
|
46
|
+
* See authConfig above for explanation of the `satisfies` pattern.
|
|
47
|
+
*
|
|
48
|
+
* This config is passed to FluidProvider to configure the API client.
|
|
49
|
+
* The callbacks (getAuthToken, onAuthError) are called by the SDK
|
|
50
|
+
* when making API requests.
|
|
51
|
+
*/
|
|
52
|
+
export const fluidConfig = {
|
|
53
|
+
/**
|
|
54
|
+
* Base URL for the Fluid API
|
|
55
|
+
* Change this to your Fluid instance URL if self-hosted
|
|
56
|
+
*/
|
|
57
|
+
baseUrl: import.meta.env.VITE_API_URL ?? "https://api.fluid.app",
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the authentication token for API requests
|
|
61
|
+
* This reads from the token stored by FluidAuthProvider
|
|
62
|
+
*/
|
|
63
|
+
getAuthToken: () => {
|
|
64
|
+
return getStoredToken() ?? null;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// 401 errors default: SDK automatically redirects to https://auth.fluid.app
|
|
68
|
+
// To customize: onAuthError: () => { clearTokens(); window.location.reload(); },
|
|
69
|
+
} satisfies FluidSDKConfig;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
|
|
5
|
+
// https://vite.dev/config/
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react(), tailwindcss()],
|
|
8
|
+
build: {
|
|
9
|
+
// Target modern browsers for smaller bundles
|
|
10
|
+
target: "esnext",
|
|
11
|
+
},
|
|
12
|
+
});
|