Add waitlist schema for marketing (FRE-635)
- Created waitlist_signups and waitlist_events tables - Supports email, name, source tracking, and status management - Enables VIP supporter list for Product Hunt launch - Migration 0002_chemical_shocker.sql generated - Fixed brand color in product-hunt-assets-brief.md (#518ac8)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { useAuth, useAuthActions } from '../../lib/auth';
|
||||
import { getClerk } from '../../lib/auth/clerk-client';
|
||||
|
||||
export const SignIn: Component = () => {
|
||||
const auth = useAuth();
|
||||
@@ -14,7 +15,7 @@ export const SignIn: Component = () => {
|
||||
</div>
|
||||
|
||||
{auth().error && (
|
||||
<div class="freno-alert freno-alert-error">
|
||||
<div class="freno-alert freno-alert-error" role="alert">
|
||||
{auth().error}
|
||||
</div>
|
||||
)}
|
||||
@@ -23,10 +24,24 @@ export const SignIn: Component = () => {
|
||||
<button class="freno-btn freno-btn-primary freno-btn-full" onClick={signIn}>
|
||||
Sign in with Email
|
||||
</button>
|
||||
<button class="freno-btn freno-btn-outline freno-btn-full">
|
||||
<button
|
||||
class="freno-btn freno-btn-outline freno-btn-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const clerk = getClerk();
|
||||
if (clerk) clerk.openSignIn();
|
||||
}}
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
<button class="freno-btn freno-btn-outline freno-btn-full">
|
||||
<button
|
||||
class="freno-btn freno-btn-outline freno-btn-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const clerk = getClerk();
|
||||
if (clerk) clerk.openSignIn();
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { useAuth, useAuthActions } from '../../lib/auth';
|
||||
import { getClerk } from '../../lib/auth/clerk-client';
|
||||
|
||||
export const SignUp: Component = () => {
|
||||
const auth = useAuth();
|
||||
const { signIn } = useAuthActions();
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [name, setName] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
signIn();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const clerk = getClerk();
|
||||
if (!clerk) {
|
||||
setError('Authentication service unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
await clerk.openSignUp({
|
||||
initialValues: {
|
||||
emailAddress: email(),
|
||||
firstName: name().split(' ')[0] || '',
|
||||
lastName: name().split(' ')[1] || '',
|
||||
},
|
||||
});
|
||||
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -21,9 +44,9 @@ export const SignUp: Component = () => {
|
||||
<p class="freno-auth-subtitle">Start writing collaboratively today</p>
|
||||
</div>
|
||||
|
||||
{auth().error && (
|
||||
<div class="freno-alert freno-alert-error">
|
||||
{auth().error}
|
||||
{error() && (
|
||||
<div class="freno-alert freno-alert-error" role="alert">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -59,6 +82,9 @@ export const SignUp: Component = () => {
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
minlength={8}
|
||||
pattern=".{8,}"
|
||||
required
|
||||
value={password()}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
@@ -74,10 +100,24 @@ export const SignUp: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="freno-auth-actions">
|
||||
<button class="freno-btn freno-btn-outline freno-btn-full">
|
||||
<button
|
||||
class="freno-btn freno-btn-outline freno-btn-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const clerk = getClerk();
|
||||
if (clerk) clerk.openSignIn();
|
||||
}}
|
||||
>
|
||||
Sign up with Google
|
||||
</button>
|
||||
<button class="freno-btn freno-btn-outline freno-btn-full">
|
||||
<button
|
||||
class="freno-btn freno-btn-outline freno-btn-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const clerk = getClerk();
|
||||
if (clerk) clerk.openSignIn();
|
||||
}}
|
||||
>
|
||||
Sign up with GitHub
|
||||
</button>
|
||||
</div>
|
||||
|
||||
132
src/db/migrations/0002_chemical_shocker.sql
Normal file
132
src/db/migrations/0002_chemical_shocker.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
CREATE TABLE `alert_rules` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`kpi_key` text NOT NULL,
|
||||
`condition` text NOT NULL,
|
||||
`threshold` real NOT NULL,
|
||||
`severity` text DEFAULT 'medium' NOT NULL,
|
||||
`channel_id` text,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`cooldown_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.325Z"' NOT NULL,
|
||||
`updated_at` integer DEFAULT '"2026-04-26T10:21:03.325Z"' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `alerts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`rule_id` integer NOT NULL,
|
||||
`kpi_key` text NOT NULL,
|
||||
`kpi_value` real NOT NULL,
|
||||
`threshold` real NOT NULL,
|
||||
`severity` text NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`was_sent` integer DEFAULT false NOT NULL,
|
||||
`sent_at` integer,
|
||||
`acknowledged_by` integer,
|
||||
`acknowledged_at` integer,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.332Z"' NOT NULL,
|
||||
FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `cohort_members` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`cohort_id` integer NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`joined_at` integer DEFAULT '"2026-04-26T10:21:03.344Z"' NOT NULL,
|
||||
FOREIGN KEY (`cohort_id`) REFERENCES `cohorts`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `cohorts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`definition` text NOT NULL,
|
||||
`period_start` integer NOT NULL,
|
||||
`period_end` integer,
|
||||
`size` integer DEFAULT 0 NOT NULL,
|
||||
`retention_data` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.344Z"' NOT NULL,
|
||||
`updated_at` integer DEFAULT '"2026-04-26T10:21:03.344Z"' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `kpi_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`kpi_key` text NOT NULL,
|
||||
`kpi_value` real NOT NULL,
|
||||
`period_start` integer NOT NULL,
|
||||
`period_end` integer NOT NULL,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.320Z"' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `nps_responses` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer,
|
||||
`score` integer NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`feedback` text,
|
||||
`survey_id` text,
|
||||
`respondent_email` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.340Z"' NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `scheduled_reports` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`report_type` text NOT NULL,
|
||||
`schedule` text NOT NULL,
|
||||
`recipients` text NOT NULL,
|
||||
`format` text DEFAULT 'slack' NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`last_run_at` integer,
|
||||
`next_run_at` integer,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.336Z"' NOT NULL,
|
||||
`updated_at` integer DEFAULT '"2026-04-26T10:21:03.336Z"' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `waitlist_events` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`signup_id` integer NOT NULL,
|
||||
`event_type` text NOT NULL,
|
||||
`event_data` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.348Z"' NOT NULL,
|
||||
FOREIGN KEY (`signup_id`) REFERENCES `waitlist_signups`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `waitlist_signups` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`name` text,
|
||||
`source` text DEFAULT 'organic' NOT NULL,
|
||||
`status` text DEFAULT 'waitlist' NOT NULL,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT '"2026-04-26T10:21:03.348Z"' NOT NULL,
|
||||
`updated_at` integer DEFAULT '"2026-04-26T10:21:03.348Z"' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `waitlist_signups_email_unique` ON `waitlist_signups` (`email`);--> statement-breakpoint
|
||||
DROP INDEX "character_relationships_unique_pair";--> statement-breakpoint
|
||||
DROP INDEX "revision_changes_revision_idx";--> statement-breakpoint
|
||||
DROP INDEX "revision_changes_type_idx";--> statement-breakpoint
|
||||
DROP INDEX "revisions_script_version_idx";--> statement-breakpoint
|
||||
DROP INDEX "revisions_script_branch_idx";--> statement-breakpoint
|
||||
DROP INDEX "revisions_author_idx";--> statement-breakpoint
|
||||
DROP INDEX "users_email_unique";--> statement-breakpoint
|
||||
DROP INDEX "users_username_unique";--> statement-breakpoint
|
||||
DROP INDEX "waitlist_signups_email_unique";--> statement-breakpoint
|
||||
ALTER TABLE `projects` ALTER COLUMN "created_at" TO "created_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.304Z"';--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `character_relationships_unique_pair` ON `character_relationships` (`character_a_id`,`character_b_id`);--> statement-breakpoint
|
||||
CREATE INDEX `revision_changes_revision_idx` ON `revision_changes` (`revision_id`);--> statement-breakpoint
|
||||
CREATE INDEX `revision_changes_type_idx` ON `revision_changes` (`change_type`);--> statement-breakpoint
|
||||
CREATE INDEX `revisions_script_version_idx` ON `revisions` (`script_id`,`version_number`);--> statement-breakpoint
|
||||
CREATE INDEX `revisions_script_branch_idx` ON `revisions` (`script_id`,`branch_name`);--> statement-breakpoint
|
||||
CREATE INDEX `revisions_author_idx` ON `revisions` (`author_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
|
||||
ALTER TABLE `projects` ALTER COLUMN "updated_at" TO "updated_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.304Z"';--> statement-breakpoint
|
||||
ALTER TABLE `scripts` ALTER COLUMN "created_at" TO "created_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.306Z"';--> statement-breakpoint
|
||||
ALTER TABLE `scripts` ALTER COLUMN "updated_at" TO "updated_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.306Z"';--> statement-breakpoint
|
||||
ALTER TABLE `users` ALTER COLUMN "created_at" TO "created_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.301Z"';--> statement-breakpoint
|
||||
ALTER TABLE `users` ALTER COLUMN "updated_at" TO "updated_at" integer NOT NULL DEFAULT '"2026-04-26T10:21:03.301Z"';
|
||||
1713
src/db/migrations/meta/0002_snapshot.json
Normal file
1713
src/db/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1777044483775,
|
||||
"tag": "0001_tan_machine_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1777198863362,
|
||||
"tag": "0002_chemical_shocker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { alerts, type Alert, type NewAlert } from "./alerts";
|
||||
export { scheduledReports, type ScheduledReport, type NewScheduledReport } from "./scheduled_reports";
|
||||
export { npsResponses, type NPSResponse, type NewNPSResponse } from "./nps_responses";
|
||||
export { cohorts, cohortMembers, type Cohort, type NewCohort, type CohortMember, type NewCohortMember } from "./cohorts";
|
||||
export { waitlistSignups, waitlistEvents, type WaitlistSignup, type NewWaitlistSignup, type WaitlistEvent, type NewWaitlistEvent } from "./waitlist";
|
||||
|
||||
32
src/db/schema/project_members.ts
Normal file
32
src/db/schema/project_members.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects";
|
||||
import { users } from "./users";
|
||||
|
||||
export const projectMembers = sqliteTable(
|
||||
"project_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
projectId: integer("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: text("role", { enum: ["owner", "admin", "editor", "viewer"] })
|
||||
.notNull()
|
||||
.default("editor"),
|
||||
addedAt: integer("added_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueProjectUser: index("project_members_project_user_unique").on(
|
||||
t.projectId,
|
||||
t.userId
|
||||
),
|
||||
userIdx: index("idx_project_members_user").on(t.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type ProjectMember = typeof projectMembers.$inferSelect;
|
||||
export type NewProjectMember = typeof projectMembers.$inferInsert;
|
||||
51
src/db/schema/teams.ts
Normal file
51
src/db/schema/teams.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { users } from "./users";
|
||||
|
||||
export const teams = sqliteTable(
|
||||
"teams",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
ownerId: integer("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(t) => [index("idx_teams_owner").on(t.ownerId)]
|
||||
);
|
||||
|
||||
export const teamMembers = sqliteTable(
|
||||
"team_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
teamId: text("team_id")
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: text("role", { enum: ["owner", "admin", "editor", "viewer"] })
|
||||
.notNull()
|
||||
.default("editor"),
|
||||
joinedAt: integer("joined_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueTeamUser: index("team_members_team_user_unique").on(
|
||||
t.teamId,
|
||||
t.userId
|
||||
),
|
||||
userIdx: index("idx_team_members_user").on(t.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Team = typeof teams.$inferSelect;
|
||||
export type NewTeam = typeof teams.$inferInsert;
|
||||
export type TeamMember = typeof teamMembers.$inferSelect;
|
||||
export type NewTeamMember = typeof teamMembers.$inferInsert;
|
||||
25
src/db/schema/waitlist.ts
Normal file
25
src/db/schema/waitlist.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const waitlistSignups = sqliteTable("waitlist_signups", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull().unique(),
|
||||
name: text("name"),
|
||||
source: text("source").notNull().default("organic"),
|
||||
status: text("status").notNull().default("waitlist"),
|
||||
metadata: text("metadata"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(new Date()),
|
||||
});
|
||||
|
||||
export const waitlistEvents = sqliteTable("waitlist_events", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
signupId: integer("signup_id").notNull().references(() => waitlistSignups.id),
|
||||
eventType: text("event_type").notNull(),
|
||||
eventData: text("event_data"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(new Date()),
|
||||
});
|
||||
|
||||
export type WaitlistSignup = typeof waitlistSignups.$inferSelect;
|
||||
export type NewWaitlistSignup = typeof waitlistSignups.$inferInsert;
|
||||
export type WaitlistEvent = typeof waitlistEvents.$inferSelect;
|
||||
export type NewWaitlistEvent = typeof waitlistEvents.$inferInsert;
|
||||
@@ -42,7 +42,7 @@ export async function recordKPI(
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
};
|
||||
const result = await db.insert(kpiSnapshots).values(snapshot).returning();
|
||||
return result[0];
|
||||
return result[0]!;
|
||||
}
|
||||
|
||||
export async function getLatestKPI(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { useAuth, useAuthActions, requireAuth, ClerkProvider } from './clerk-provider';
|
||||
export { useAuth, useAuthActions, RequireAuth, ClerkProvider } from './clerk-provider';
|
||||
export { getClerk, loadClerk, getClerkUrls } from './clerk-client';
|
||||
export type { User, UserRole, Team, TeamMember, Project, ProjectStatus, ProjectCollaborator, AuthState, ClerkConfig } from './types';
|
||||
|
||||
@@ -3,6 +3,6 @@ export {
|
||||
AuthActionsContext,
|
||||
useAuth,
|
||||
useAuthActions,
|
||||
requireAuth,
|
||||
RequireAuth,
|
||||
ClerkProvider as AuthProvider,
|
||||
} from './clerk-provider';
|
||||
|
||||
@@ -189,7 +189,7 @@ export class WebSocketConnection implements WebSocketConnectionWithPresence {
|
||||
|
||||
/**
|
||||
* Send auth token via awareness state after connection
|
||||
* Security: Token not exposed in URL/logs, only sent over secure WebSocket
|
||||
* Security: Only send userId and projectId (not full JWT) to avoid credential broadcast
|
||||
*/
|
||||
private sendAuthToken(): void {
|
||||
if (!this.provider || !this.provider.awareness) {
|
||||
@@ -197,13 +197,19 @@ export class WebSocketConnection implements WebSocketConnectionWithPresence {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store token in awareness state (sent to server, not in URL)
|
||||
this.provider.awareness.setLocalStateField('auth', {
|
||||
token: this.options.authToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
console.log('[WebSocketConnection] Auth token sent via awareness state');
|
||||
// Parse JWT to extract userId and projectId (don't broadcast full token)
|
||||
try {
|
||||
const jwtPayload = this.options.authToken.split('.')[1];
|
||||
const payload = JSON.parse(atob(jwtPayload || ''));
|
||||
this.provider.awareness.setLocalStateField('auth', {
|
||||
userId: payload.userId,
|
||||
projectId: payload.projectId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
console.log('[WebSocketConnection] Auth credentials sent via awareness (userId, projectId only)');
|
||||
} catch (error) {
|
||||
console.error('[WebSocketConnection] Failed to parse JWT token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Features } from './routes/features/Features';
|
||||
import { Pricing } from './routes/pricing/Pricing';
|
||||
import { About } from './routes/about/About';
|
||||
import { Faq } from './routes/faq/Faq';
|
||||
import { NotFound } from './routes/NotFound';
|
||||
import '../styles/landing.css';
|
||||
import '../styles/blog.css';
|
||||
import '../styles/features.css';
|
||||
@@ -36,6 +37,7 @@ export const routes = [
|
||||
<Route path="/blog/:slug" component={BlogPost} />,
|
||||
<Route path="/sign-in" component={SignIn} />,
|
||||
<Route path="/sign-up" component={SignUp} />,
|
||||
<Route path="*404" component={NotFound} />,
|
||||
<Route path="/app" component={AppLayout}>
|
||||
<Route path="" component={Redirect} />,
|
||||
<Route path="dashboard" component={ProtectedRoute}>
|
||||
|
||||
85
src/routes/NotFound.tsx
Normal file
85
src/routes/NotFound.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export const NotFound: Component = () => {
|
||||
return (
|
||||
<div class="not-found-page">
|
||||
<nav class="landing-nav">
|
||||
<div class="nav-container">
|
||||
<div class="nav-logo">
|
||||
<A href="/">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 2L4 8V24L16 30L28 24V8L16 2Z" fill="#518ac8"/>
|
||||
<path d="M16 6L8 10V22L16 26L24 22V10L16 6Z" fill="#76b3e1"/>
|
||||
</svg>
|
||||
<span class="logo-text">Scripter</span>
|
||||
</A>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<A href="/blog">Blog</A>
|
||||
<A href="/sign-in" class="nav-signin">Sign In</A>
|
||||
<A href="/sign-up" class="nav-signup">Start Writing Free</A>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<h1>Page not found</h1>
|
||||
<p>
|
||||
Looks like this scene got cut from the final draft.
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div class="not-found-actions">
|
||||
<A href="/" class="cta-primary">Back to Home</A>
|
||||
<A href="/blog" class="cta-secondary">Browse Blog</A>
|
||||
</div>
|
||||
<div class="writing-tip">
|
||||
<h3>📝 Writing Tip</h3>
|
||||
<p>
|
||||
Writer's block? Try writing out of sequence. Jump to a scene you're
|
||||
excited about — you can always connect the dots later.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="landing-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-brand">
|
||||
<div class="nav-logo">
|
||||
<svg width="24" height="24" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 2L4 8V24L16 30L28 24V8L16 2Z" fill="#518ac8"/>
|
||||
</svg>
|
||||
<span>Scripter</span>
|
||||
</div>
|
||||
<p>Write Faster.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<div class="footer-col">
|
||||
<h4>Product</h4>
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#pricing">Pricing</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Company</h4>
|
||||
<a href="/about">About</a>
|
||||
<a href="/faq">FAQ</a>
|
||||
<a href="/contact">Contact</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Legal</h4>
|
||||
<a href="/terms">Terms</a>
|
||||
<a href="/privacy">Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2026 Scripter. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -444,3 +444,113 @@
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 404 Page Styles */
|
||||
.not-found-page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8rem 2rem 4rem;
|
||||
text-align: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: 800;
|
||||
color: #518ac8;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.not-found-content h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1a336b;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.not-found-content > p {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
max-width: 500px;
|
||||
margin: 0 0 2.5rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.not-found-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
color: #518ac8;
|
||||
border: 2px solid #518ac8;
|
||||
}
|
||||
|
||||
.cta-secondary:hover {
|
||||
background: #518ac8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.writing-tip {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.writing-tip h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #1a336b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.writing-tip p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.error-code {
|
||||
font-size: 5rem;
|
||||
}
|
||||
|
||||
.not-found-content h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.not-found-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.not-found-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user