@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,80 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@fluid-app/portal-widgets/globals.css";
|
|
3
|
+
@import "@fluid-app/messaging-ui/styles/messaging.css";
|
|
4
|
+
@source "../node_modules/@fluid-app/**/dist/**/*.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Light-mode fallback tokens (OKLCH format).
|
|
8
|
+
*
|
|
9
|
+
* The theme engine injects higher-specificity CSS via [data-theme="..."]
|
|
10
|
+
* which overrides these at runtime for both light and dark modes.
|
|
11
|
+
*
|
|
12
|
+
* Canonical variables use the --color- prefix to match the css-generator
|
|
13
|
+
* output and the widgets tailwind.config.ts color palette registration.
|
|
14
|
+
* Short-name aliases (--background, --foreground, etc.) are included for
|
|
15
|
+
* backward compatibility with shadcn/Radix components.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
:root {
|
|
19
|
+
--color-background: oklch(1 0 0);
|
|
20
|
+
--color-foreground: oklch(0.145 0 0);
|
|
21
|
+
--color-primary: oklch(0.205 0 0);
|
|
22
|
+
--color-primary-foreground: oklch(0.985 0 0);
|
|
23
|
+
--color-secondary: oklch(0.97 0 0);
|
|
24
|
+
--color-secondary-foreground: oklch(0.205 0 0);
|
|
25
|
+
--color-muted: oklch(0.97 0 0);
|
|
26
|
+
--color-muted-foreground: oklch(0.556 0 0);
|
|
27
|
+
--color-accent: oklch(0.97 0 0);
|
|
28
|
+
--color-accent-foreground: oklch(0.205 0 0);
|
|
29
|
+
--color-destructive: oklch(0.577 0.245 27.325);
|
|
30
|
+
--color-destructive-foreground: oklch(0.985 0 0);
|
|
31
|
+
--color-border: oklch(0.922 0 0);
|
|
32
|
+
|
|
33
|
+
/* Short-name aliases (match css-generator globalCSSOverride) */
|
|
34
|
+
--background: var(--color-background);
|
|
35
|
+
--foreground: var(--color-foreground);
|
|
36
|
+
--primary: var(--color-primary);
|
|
37
|
+
--primary-foreground: var(--color-primary-foreground);
|
|
38
|
+
--secondary: var(--color-secondary);
|
|
39
|
+
--secondary-foreground: var(--color-secondary-foreground);
|
|
40
|
+
--muted: var(--color-muted);
|
|
41
|
+
--muted-foreground: var(--color-muted-foreground);
|
|
42
|
+
--accent: var(--color-accent);
|
|
43
|
+
--accent-foreground: var(--color-accent-foreground);
|
|
44
|
+
--destructive: var(--color-destructive);
|
|
45
|
+
--destructive-foreground: var(--color-destructive-foreground);
|
|
46
|
+
--border: var(--color-border);
|
|
47
|
+
--input: var(--color-border);
|
|
48
|
+
--ring: var(--color-primary);
|
|
49
|
+
--radius: 0.625rem;
|
|
50
|
+
|
|
51
|
+
/* Sidebar aliases */
|
|
52
|
+
--sidebar: var(--color-muted);
|
|
53
|
+
--sidebar-foreground: var(--color-muted-foreground);
|
|
54
|
+
--sidebar-primary: var(--color-primary);
|
|
55
|
+
--sidebar-primary-foreground: var(--color-primary-foreground);
|
|
56
|
+
--sidebar-accent: var(--color-accent);
|
|
57
|
+
--sidebar-accent-foreground: var(--color-accent-foreground);
|
|
58
|
+
--sidebar-border: var(--color-border);
|
|
59
|
+
--sidebar-ring: var(--color-primary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@layer base {
|
|
63
|
+
* {
|
|
64
|
+
@apply border-border;
|
|
65
|
+
}
|
|
66
|
+
body {
|
|
67
|
+
@apply bg-background text-foreground;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Hide scrollbars while preserving scroll functionality */
|
|
72
|
+
@layer utilities {
|
|
73
|
+
.scrollbar-none {
|
|
74
|
+
-ms-overflow-style: none;
|
|
75
|
+
scrollbar-width: none;
|
|
76
|
+
}
|
|
77
|
+
.scrollbar-none::-webkit-scrollbar {
|
|
78
|
+
display: none;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
FluidAuthProvider,
|
|
5
|
+
FluidProvider,
|
|
6
|
+
RequireAuth,
|
|
7
|
+
} from "@fluid-app/portal-sdk";
|
|
8
|
+
import { authConfig, fluidConfig } from "./fluid.config";
|
|
9
|
+
import App from "./App";
|
|
10
|
+
import "./index.css";
|
|
11
|
+
|
|
12
|
+
// Find the root element
|
|
13
|
+
const rootElement = document.getElementById("root");
|
|
14
|
+
if (!rootElement) {
|
|
15
|
+
throw new Error("Root element not found");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* App Wrapper with Authentication
|
|
20
|
+
*
|
|
21
|
+
* The provider hierarchy is:
|
|
22
|
+
* 1. FluidAuthProvider - Handles token extraction, validation, storage
|
|
23
|
+
* 2. FluidProvider - Fluid SDK context (API client, theme, QueryClientProvider)
|
|
24
|
+
* 3. RequireAuth - Only renders children when authenticated
|
|
25
|
+
*
|
|
26
|
+
* Authentication flow:
|
|
27
|
+
* 1. Parent Fluid app opens this portal with ?fluidUserToken=... in URL
|
|
28
|
+
* 2. FluidAuthProvider extracts token, validates it, stores in cookie/localStorage
|
|
29
|
+
* 3. Token is removed from URL (security)
|
|
30
|
+
* 4. RequireAuth shows loading while checking, then renders app or error
|
|
31
|
+
*/
|
|
32
|
+
createRoot(rootElement).render(
|
|
33
|
+
<StrictMode>
|
|
34
|
+
<FluidAuthProvider config={authConfig}>
|
|
35
|
+
<FluidProvider config={fluidConfig}>
|
|
36
|
+
<RequireAuth>
|
|
37
|
+
<App />
|
|
38
|
+
</RequireAuth>
|
|
39
|
+
</FluidProvider>
|
|
40
|
+
</FluidAuthProvider>
|
|
41
|
+
</StrictMode>,
|
|
42
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-defined navigation items.
|
|
3
|
+
*
|
|
4
|
+
* Synced to the Fluid OS API on `fluid deploy` with source: "code".
|
|
5
|
+
* Position is derived from array order. Items without a slug are
|
|
6
|
+
* treated as section headers.
|
|
7
|
+
*
|
|
8
|
+
* User-created navigation items (from the admin dashboard) are not
|
|
9
|
+
* affected — only items with source: "code" are managed.
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT: Do not add import statements to this file. The Fluid CLI
|
|
12
|
+
* reads this file without resolving dependencies, so it must be
|
|
13
|
+
* self-contained static data only.
|
|
14
|
+
*/
|
|
15
|
+
export const navigation = [
|
|
16
|
+
{
|
|
17
|
+
label: "Dashboard",
|
|
18
|
+
slug: "dashboard",
|
|
19
|
+
icon: "home",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Configuration
|
|
3
|
+
*
|
|
4
|
+
* Register custom page components here. These are rendered by AppShell
|
|
5
|
+
* when a navigation item's slug matches the key in this map.
|
|
6
|
+
*
|
|
7
|
+
* Navigation structure is defined in navigation.config.ts and synced to
|
|
8
|
+
* the Fluid OS API on `fluid deploy`.
|
|
9
|
+
*
|
|
10
|
+
* QUICK START:
|
|
11
|
+
* 1. Create a new screen component in ./screens/
|
|
12
|
+
* 2. Import it below and add it to customPages
|
|
13
|
+
* 3. Add a matching entry to src/navigation.config.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ComponentType } from "react";
|
|
17
|
+
import { DashboardScreen } from "./screens/Dashboard";
|
|
18
|
+
// Uncomment after installing form deps (pnpm add react-hook-form zod @hookform/resolvers):
|
|
19
|
+
// import { ExampleFormScreen } from "./screens/ExampleForm";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Custom page components keyed by navigation slug.
|
|
23
|
+
*
|
|
24
|
+
* When AppShell resolves a navigation item, it checks this map first.
|
|
25
|
+
* If a matching slug is found here, the custom component is rendered
|
|
26
|
+
* instead of the default system screen or placeholder.
|
|
27
|
+
*/
|
|
28
|
+
export const customPages: Record<string, ComponentType> = {
|
|
29
|
+
dashboard: DashboardScreen,
|
|
30
|
+
// Add more custom screens here:
|
|
31
|
+
// "example-form": ExampleFormScreen,
|
|
32
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TextWidget,
|
|
3
|
+
ChartWidget,
|
|
4
|
+
ToDoWidget,
|
|
5
|
+
RecentActivityWidget,
|
|
6
|
+
CalendarWidget,
|
|
7
|
+
} from "@fluid-app/portal-sdk";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sample chart data extracted as a module-level constant.
|
|
11
|
+
*
|
|
12
|
+
* The data is extracted as a constant to:
|
|
13
|
+
* 1. Keep the component cleaner
|
|
14
|
+
* 2. Allow reuse/testing if needed
|
|
15
|
+
* 3. Make it clear the data is static
|
|
16
|
+
*/
|
|
17
|
+
const MONTHLY_PERFORMANCE_DATA = [
|
|
18
|
+
{ name: "Jan", value: 4000 },
|
|
19
|
+
{ name: "Feb", value: 3000 },
|
|
20
|
+
{ name: "Mar", value: 5000 },
|
|
21
|
+
{ name: "Apr", value: 4500 },
|
|
22
|
+
{ name: "May", value: 6000 },
|
|
23
|
+
{ name: "Jun", value: 5500 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dashboard Screen
|
|
28
|
+
*
|
|
29
|
+
* This is the default landing screen for the portal.
|
|
30
|
+
* It demonstrates using various widgets from portal-sdk:
|
|
31
|
+
*
|
|
32
|
+
* - TextWidget: Display text content with optional title
|
|
33
|
+
* - ToDoWidget: Task list (uses useTodos hook)
|
|
34
|
+
* - RecentActivityWidget: Activity feed (uses useActivities hook)
|
|
35
|
+
* - ChartWidget: Bar, line, area, pie charts
|
|
36
|
+
* - CalendarWidget: Calendar view (uses useCalendarEvents hook)
|
|
37
|
+
*
|
|
38
|
+
* These widgets fetch data from hooks that connect to your Fluid API.
|
|
39
|
+
* In development, they show demo data. Configure fluid.config.ts
|
|
40
|
+
* to connect to your Fluid instance.
|
|
41
|
+
*/
|
|
42
|
+
export function DashboardScreen() {
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-6">
|
|
45
|
+
{/* Welcome section */}
|
|
46
|
+
<TextWidget
|
|
47
|
+
titleEnabled
|
|
48
|
+
title="Welcome to Your Portal"
|
|
49
|
+
description="This is your portal dashboard. Use widgets from @fluid-app/portal-sdk to build powerful interfaces for your team."
|
|
50
|
+
padding={4}
|
|
51
|
+
borderRadius="lg"
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
{/* Stats row - sample static data */}
|
|
55
|
+
<div className="grid gap-6 md:grid-cols-3">
|
|
56
|
+
<div className="bg-background rounded-lg p-4 shadow-sm">
|
|
57
|
+
<p className="text-foreground text-3xl font-bold">$12,450</p>
|
|
58
|
+
<p className="text-muted-foreground mt-1 text-sm">Total Sales</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div className="bg-background rounded-lg p-4 shadow-sm">
|
|
62
|
+
<p className="text-foreground text-3xl font-bold">24</p>
|
|
63
|
+
<p className="text-muted-foreground mt-1 text-sm">Active Orders</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="bg-background rounded-lg p-4 shadow-sm">
|
|
67
|
+
<p className="text-foreground text-3xl font-bold">156</p>
|
|
68
|
+
<p className="text-muted-foreground mt-1 text-sm">Customers</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Main content grid - Tasks and Activity */}
|
|
73
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
74
|
+
{/* To-Do Widget - fetches tasks via useTodos hook */}
|
|
75
|
+
<ToDoWidget
|
|
76
|
+
titleEnabled
|
|
77
|
+
titleText="Tasks"
|
|
78
|
+
maxItems={5}
|
|
79
|
+
padding={4}
|
|
80
|
+
borderRadius="lg"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
{/* Recent Activity Widget - fetches via useActivities hook */}
|
|
84
|
+
<RecentActivityWidget
|
|
85
|
+
titleEnabled
|
|
86
|
+
titleText="Recent Activity"
|
|
87
|
+
maxItemsToShow={5}
|
|
88
|
+
padding={4}
|
|
89
|
+
borderRadius="lg"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Performance chart - uses extracted constant data */}
|
|
94
|
+
<ChartWidget
|
|
95
|
+
titleEnabled
|
|
96
|
+
title="Monthly Performance"
|
|
97
|
+
chartType="bar"
|
|
98
|
+
data={MONTHLY_PERFORMANCE_DATA}
|
|
99
|
+
padding={4}
|
|
100
|
+
borderRadius="lg"
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
{/* Calendar preview - uses useCalendarEvents hook */}
|
|
104
|
+
<CalendarWidget
|
|
105
|
+
titleEnabled
|
|
106
|
+
titleText="Upcoming Events"
|
|
107
|
+
padding={4}
|
|
108
|
+
borderRadius="lg"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
{/* Getting started tips */}
|
|
112
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
113
|
+
<TextWidget
|
|
114
|
+
titleEnabled
|
|
115
|
+
title="Customize Navigation"
|
|
116
|
+
description="Edit portal.config.ts to add, remove, or reorder navigation items. Core screens (Messaging, Contacts) are built-in."
|
|
117
|
+
padding={4}
|
|
118
|
+
borderRadius="lg"
|
|
119
|
+
background={{ type: "solid", color: "muted" }}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<TextWidget
|
|
123
|
+
titleEnabled
|
|
124
|
+
title="Connect Your Data"
|
|
125
|
+
description="Configure fluid.config.ts with your Fluid API credentials. Widgets will automatically fetch real data from your account."
|
|
126
|
+
padding={4}
|
|
127
|
+
borderRadius="lg"
|
|
128
|
+
background={{ type: "solid", color: "muted" }}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
2
|
+
// @ts-nocheck — This file requires optional dependencies (react-hook-form, zod, @hookform/resolvers).
|
|
3
|
+
// Install them before removing this directive: pnpm add react-hook-form zod @hookform/resolvers
|
|
4
|
+
/**
|
|
5
|
+
* Example Form Screen
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates how to build forms in a portal using
|
|
8
|
+
* React Hook Form + Zod for validation.
|
|
9
|
+
*
|
|
10
|
+
* SETUP: Install form dependencies before using this screen:
|
|
11
|
+
* pnpm add react-hook-form zod @hookform/resolvers
|
|
12
|
+
*
|
|
13
|
+
* Then uncomment the nav entry and import in portal.config.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useForm } from "react-hook-form";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Zod Schema
|
|
22
|
+
// Define your form validation schema here
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
const contactFormSchema = z.object({
|
|
26
|
+
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
|
|
27
|
+
email: z.string().email("Invalid email address"),
|
|
28
|
+
subject: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1, "Subject is required")
|
|
31
|
+
.max(200, "Subject is too long"),
|
|
32
|
+
message: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(10, "Message must be at least 10 characters")
|
|
35
|
+
.max(2000, "Message is too long"),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
type ContactFormData = z.infer<typeof contactFormSchema>;
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Example Form Screen
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
export function ExampleFormScreen() {
|
|
45
|
+
const {
|
|
46
|
+
register,
|
|
47
|
+
handleSubmit,
|
|
48
|
+
reset,
|
|
49
|
+
formState: { errors, isSubmitting, isSubmitSuccessful },
|
|
50
|
+
} = useForm<ContactFormData>({
|
|
51
|
+
resolver: zodResolver(contactFormSchema),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const onSubmit = async (data: ContactFormData) => {
|
|
55
|
+
// Replace with your API call, e.g.:
|
|
56
|
+
// const result = await apiClient.post("/contacts", data);
|
|
57
|
+
console.log("Form submitted:", data);
|
|
58
|
+
|
|
59
|
+
// Simulate API delay
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
61
|
+
|
|
62
|
+
reset(undefined, { keepIsSubmitSuccessful: true });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="space-y-6">
|
|
67
|
+
{/* Header */}
|
|
68
|
+
<div>
|
|
69
|
+
<h1 className="text-foreground text-2xl font-bold">Example Form</h1>
|
|
70
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
71
|
+
Demonstrates React Hook Form + Zod validation. Replace this with your
|
|
72
|
+
own form.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Form Card */}
|
|
77
|
+
<div className="border-border bg-background rounded-lg border p-6 shadow-sm">
|
|
78
|
+
{isSubmitSuccessful ? (
|
|
79
|
+
<div className="border-accent bg-accent text-accent-foreground rounded-lg border p-4 text-sm">
|
|
80
|
+
Form submitted successfully! Check the console for the data.
|
|
81
|
+
</div>
|
|
82
|
+
) : (
|
|
83
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
84
|
+
{/* Name field */}
|
|
85
|
+
<div>
|
|
86
|
+
<label
|
|
87
|
+
htmlFor="name"
|
|
88
|
+
className="text-foreground block text-sm font-medium"
|
|
89
|
+
>
|
|
90
|
+
Name
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
id="name"
|
|
94
|
+
type="text"
|
|
95
|
+
{...register("name")}
|
|
96
|
+
className="border-input bg-background text-foreground focus:border-ring focus:ring-ring mt-1 block w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:ring-1 focus:outline-none"
|
|
97
|
+
placeholder="Jane Smith"
|
|
98
|
+
/>
|
|
99
|
+
{errors.name && (
|
|
100
|
+
<p className="text-destructive mt-1 text-sm">
|
|
101
|
+
{errors.name.message}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Email field */}
|
|
107
|
+
<div>
|
|
108
|
+
<label
|
|
109
|
+
htmlFor="email"
|
|
110
|
+
className="text-foreground block text-sm font-medium"
|
|
111
|
+
>
|
|
112
|
+
Email
|
|
113
|
+
</label>
|
|
114
|
+
<input
|
|
115
|
+
id="email"
|
|
116
|
+
type="email"
|
|
117
|
+
{...register("email")}
|
|
118
|
+
className="border-input bg-background text-foreground focus:border-ring focus:ring-ring mt-1 block w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:ring-1 focus:outline-none"
|
|
119
|
+
placeholder="jane@example.com"
|
|
120
|
+
/>
|
|
121
|
+
{errors.email && (
|
|
122
|
+
<p className="text-destructive mt-1 text-sm">
|
|
123
|
+
{errors.email.message}
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Subject field */}
|
|
129
|
+
<div>
|
|
130
|
+
<label
|
|
131
|
+
htmlFor="subject"
|
|
132
|
+
className="text-foreground block text-sm font-medium"
|
|
133
|
+
>
|
|
134
|
+
Subject
|
|
135
|
+
</label>
|
|
136
|
+
<input
|
|
137
|
+
id="subject"
|
|
138
|
+
type="text"
|
|
139
|
+
{...register("subject")}
|
|
140
|
+
className="border-input bg-background text-foreground focus:border-ring focus:ring-ring mt-1 block w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:ring-1 focus:outline-none"
|
|
141
|
+
placeholder="Order inquiry"
|
|
142
|
+
/>
|
|
143
|
+
{errors.subject && (
|
|
144
|
+
<p className="text-destructive mt-1 text-sm">
|
|
145
|
+
{errors.subject.message}
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Message field */}
|
|
151
|
+
<div>
|
|
152
|
+
<label
|
|
153
|
+
htmlFor="message"
|
|
154
|
+
className="text-foreground block text-sm font-medium"
|
|
155
|
+
>
|
|
156
|
+
Message
|
|
157
|
+
</label>
|
|
158
|
+
<textarea
|
|
159
|
+
id="message"
|
|
160
|
+
rows={4}
|
|
161
|
+
{...register("message")}
|
|
162
|
+
className="border-input bg-background text-foreground focus:border-ring focus:ring-ring mt-1 block w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:ring-1 focus:outline-none"
|
|
163
|
+
placeholder="Tell us more..."
|
|
164
|
+
/>
|
|
165
|
+
{errors.message && (
|
|
166
|
+
<p className="text-destructive mt-1 text-sm">
|
|
167
|
+
{errors.message.message}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Submit button */}
|
|
173
|
+
<div className="flex justify-end">
|
|
174
|
+
<button
|
|
175
|
+
type="submit"
|
|
176
|
+
disabled={isSubmitting}
|
|
177
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring focus:ring-offset-background rounded-md px-4 py-2 text-sm font-medium shadow-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
178
|
+
>
|
|
179
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</form>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"jsx": "react-jsx",
|
|
17
|
+
|
|
18
|
+
/* Linting */
|
|
19
|
+
"strict": true,
|
|
20
|
+
"noUnusedLocals": true,
|
|
21
|
+
"noUnusedParameters": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedIndexedAccess": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ─── Development ─────────────────────────────────────────────────────────────
|
|
2
|
+
VITE_API_URL=https://api.fluid.app
|
|
3
|
+
DATABASE_URL=file:local.db
|
|
4
|
+
DATABASE_AUTH_TOKEN=
|
|
5
|
+
|
|
6
|
+
# Allowed CORS origin (defaults to http://localhost:5173 in development)
|
|
7
|
+
ALLOWED_ORIGIN=
|
|
8
|
+
|
|
9
|
+
# ─── Deployment (used by `fluid deploy`) ─────────────────────────────────────
|
|
10
|
+
# Fluid company API key (required for deploy/destroy)
|
|
11
|
+
FLUID_COMPANY_API_KEY=
|
|
12
|
+
|
|
13
|
+
# Get your API token at: https://app.turso.tech/api-tokens
|
|
14
|
+
TURSO_API_TOKEN=
|
|
15
|
+
TURSO_ORG=
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
branches: [main]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
typecheck:
|
|
8
|
+
name: Typecheck
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: pnpm/action-setup@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 20
|
|
16
|
+
cache: pnpm
|
|
17
|
+
- run: pnpm install --frozen-lockfile
|
|
18
|
+
- run: pnpm typecheck
|
|
19
|
+
|
|
20
|
+
lint:
|
|
21
|
+
name: Lint
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: pnpm/action-setup@v4
|
|
26
|
+
- uses: actions/setup-node@v4
|
|
27
|
+
with:
|
|
28
|
+
node-version: 20
|
|
29
|
+
cache: pnpm
|
|
30
|
+
- run: pnpm install --frozen-lockfile
|
|
31
|
+
- run: pnpm lint
|
|
32
|
+
|
|
33
|
+
test:
|
|
34
|
+
name: Test
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
env:
|
|
37
|
+
DATABASE_URL: file:test.db
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- uses: pnpm/action-setup@v4
|
|
41
|
+
- uses: actions/setup-node@v4
|
|
42
|
+
with:
|
|
43
|
+
node-version: 20
|
|
44
|
+
cache: pnpm
|
|
45
|
+
- run: pnpm install --frozen-lockfile
|
|
46
|
+
- run: pnpm db:push
|
|
47
|
+
- run: pnpm test
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: Deploy
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build & Validate
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Enable corepack
|
|
15
|
+
run: corepack enable
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: 20
|
|
20
|
+
cache: pnpm
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: pnpm install --frozen-lockfile
|
|
24
|
+
|
|
25
|
+
- name: Build
|
|
26
|
+
run: pnpm build
|
|
27
|
+
|
|
28
|
+
deploy:
|
|
29
|
+
name: Deploy to Cloud Run
|
|
30
|
+
needs: [build]
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
- name: Authenticate to Google Cloud
|
|
36
|
+
uses: google-github-actions/auth@v2
|
|
37
|
+
with:
|
|
38
|
+
credentials_json: ${{ secrets.GCP_SA_JSON }}
|
|
39
|
+
|
|
40
|
+
- name: Set up Cloud SDK
|
|
41
|
+
uses: google-github-actions/setup-gcloud@v2
|
|
42
|
+
|
|
43
|
+
- name: Get service name
|
|
44
|
+
id: meta
|
|
45
|
+
run: echo "service=$(jq -r .name package.json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/^-\|-$//g')" >> "$GITHUB_OUTPUT"
|
|
46
|
+
|
|
47
|
+
- name: Deploy to Cloud Run
|
|
48
|
+
run: |
|
|
49
|
+
gcloud run deploy ${{ steps.meta.outputs.service }} \
|
|
50
|
+
--source . \
|
|
51
|
+
--region ${{ vars.CLOUD_RUN_REGION || 'us-central1' }} \
|
|
52
|
+
--project ${{ vars.GCP_PROJECT }} \
|
|
53
|
+
--allow-unauthenticated \
|
|
54
|
+
--set-env-vars="DATABASE_URL=${{ secrets.DATABASE_URL }},DATABASE_AUTH_TOKEN=${{ secrets.DATABASE_AUTH_TOKEN }},NODE_ENV=production"
|