Merge pull request #1 from LukeHagar/feat/initial-saas-template-setup

This commit is contained in:
Luke Hagar
2025-05-27 17:28:41 -05:00
committed by GitHub
55 changed files with 12737 additions and 1 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -1 +1,38 @@
# Sassy
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -0,0 +1,191 @@
# SvelteKit Supabase Authentication Setup
This project uses Supabase for authentication with full server-side rendering (SSR) support through SvelteKit hooks.
## Features
- ✅ Email/Password authentication
- ✅ GitHub OAuth integration
- ✅ Server-side authentication with `hooks.server.ts`
- ✅ Protected routes with automatic redirects
- ✅ Session management across client and server
- ✅ TypeScript support with proper types
## Setup Instructions
### 1. Environment Variables
Create a `.env.local` file in your project root:
```bash
PUBLIC_SUPABASE_URL=your_supabase_project_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
Get these values from your Supabase project dashboard under Settings > API.
### 2. Supabase Configuration
#### Enable GitHub OAuth Provider
1. Go to your Supabase project dashboard
2. Navigate to **Authentication > Providers**
3. Enable the **GitHub** provider
4. Add your GitHub OAuth credentials:
- **Client ID**: From your GitHub OAuth app
- **Client Secret**: From your GitHub OAuth app
#### GitHub OAuth App Setup
1. Go to GitHub Settings > Developer settings > OAuth Apps
2. Click "New OAuth App"
3. Fill in the details:
- **Application name**: Your app name
- **Homepage URL**: `http://localhost:5173` (for development)
- **Authorization callback URL**: `https://your-project-ref.supabase.co/auth/v1/callback`
4. Note the Client ID and Client Secret for Supabase configuration
### 3. Supabase Authentication Settings
In your Supabase project settings:
1. Go to **Authentication > Settings**
2. Add your site URLs:
- **Site URL**: `http://localhost:5173` (development) / `https://yourdomain.com` (production)
- **Redirect URLs**: Add both your local and production URLs
## Architecture Overview
### Server-Side Authentication (`hooks.server.ts`)
The authentication is handled through SvelteKit hooks:
```typescript
// src/hooks.server.ts
export const handle: Handle = sequence(supabase, authGuard);
```
#### Features:
- **`supabase` hook**: Creates server-side Supabase client with cookie management
- **`authGuard` hook**: Protects routes and handles redirects
- **Session validation**: Validates JWTs on every request
- **Cookie management**: Automatic token refresh and cookie handling
### Protected Routes
Routes are automatically protected based on path patterns:
- **`/dashboard/*`**: Requires authentication, redirects to `/auth/login` if not authenticated
- **`/auth/*`**: Redirects authenticated users to `/dashboard`
### Client-Side Integration (`+layout.svelte`)
The root layout handles client-side authentication state:
```typescript
// Syncs server-side session with client-side
$effect(() => {
session = data.session;
});
// Handles auth state changes
supabase.auth.onAuthStateChange((event, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
invalidateAll();
}
});
```
## File Structure
```
src/
├── hooks.server.ts # Server-side authentication hooks
├── app.d.ts # TypeScript definitions for Supabase
├── lib/
│ ├── index.ts # Exports (toaster)
│ └── supabaseClient.ts # Browser Supabase client
├── routes/
│ ├── +layout.svelte # Root layout with auth state
│ ├── +layout.server.ts # Server layout with session data
│ ├── auth/
│ │ ├── login/+page.svelte # Login page with GitHub OAuth
│ │ ├── signup/+page.svelte # Signup page with GitHub OAuth
│ │ └── logout/+page.server.ts # Logout action
│ └── dashboard/
│ ├── +layout.server.ts # Dashboard layout (protected)
│ └── +page.svelte # Dashboard page
```
## Usage Examples
### Login Page Features
- Email/password authentication
- GitHub OAuth login
- Form validation and error handling
- Loading states
- Automatic redirect after login
### Signup Page Features
- Email/password registration
- GitHub OAuth signup
- Email confirmation handling
- Error handling with toast notifications
### Dashboard Protection
The dashboard is automatically protected and will:
- Redirect unauthenticated users to login
- Display user information for authenticated users
- Handle session expiration gracefully
## TypeScript Support
Full TypeScript support with proper types:
```typescript
// app.d.ts
interface Locals {
supabase: SupabaseClient;
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
session: Session | null;
user: User | null;
}
```
## Development
1. Start your development server:
```bash
npm run dev
```
2. Test authentication:
- Visit `/auth/signup` to create an account
- Try GitHub OAuth login
- Visit `/dashboard` to see protected content
- Test logout functionality
## Security Features
- **JWT validation**: Server-side validation of authentication tokens
- **Automatic refresh**: Tokens are refreshed automatically
- **Secure cookies**: HTTP-only cookies for session management
- **Route protection**: Server-side route guards
- **CSRF protection**: Built into Supabase auth flow
## Troubleshooting
### Common Issues
1. **"Cannot redirect during render"**: Usually caused by trying to redirect in a `load` function without throwing the redirect
2. **OAuth callback errors**: Check your GitHub OAuth app callback URL matches Supabase settings
3. **Session not persisting**: Ensure cookies are configured correctly in `hooks.server.ts`
### Debug Tips
- Check browser Network tab for auth requests
- Verify Supabase project settings match your configuration
- Test OAuth flow in incognito mode to avoid cached sessions

392
docs/BLOG_SETUP.md Normal file
View File

@@ -0,0 +1,392 @@
# Markdown Blog Setup with MDSvex (Svelte 5 Compatible)
This project uses a static markdown-based blog system powered by MDSvex for SvelteKit. This implementation is optimized for Svelte 5 and uses component-based rendering instead of HTML content strings.
## Features
-**Markdown Support**: Write posts in markdown with full syntax support
-**Frontmatter Metadata**: YAML frontmatter for post metadata
-**Featured Posts**: Mark posts as featured for special display
-**Tags & Categories**: Organize posts with tags
-**SEO Optimized**: Automatic meta tags and Open Graph support
-**Responsive Design**: Mobile-friendly blog layout
-**Syntax Highlighting**: Code blocks with Shiki highlighting
-**Table of Contents**: Automatic TOC generation
-**Svelte 5 Compatible**: Uses component-based rendering
## File Structure
```
src/
├── lib/
│ ├── posts/ # Markdown blog posts
│ │ ├── getting-started.md
│ │ ├── advanced-features.md
│ │ └── building-integrations.md
│ ├── components/
│ │ └── MDXLayout.svelte # Layout for mdx content
│ ├── blog.ts # Blog utilities and types
│ └── blog-utils.ts # Helper functions
├── routes/
│ └── blog/
│ ├── +page.svelte # Blog index page
│ ├── +page.server.ts # Load all posts
│ └── [slug]/
│ ├── +page.svelte # Individual post page
│ └── +page.server.ts # Load single post
└── docs/
└── BLOG_SETUP.md # This documentation
```
## Creating Blog Posts
### 1. Create a New Markdown File
Create a new `.md` file in `src/lib/posts/`:
```bash
touch src/lib/posts/my-new-post.md
```
### 2. Add Frontmatter
Start your post with YAML frontmatter:
```markdown
---
title: "My Awesome Blog Post"
slug: "my-awesome-blog-post"
excerpt: "A compelling description of your blog post that will appear in listings and meta tags."
publishedAt: "2024-01-30"
author: "Your Name"
tags: ["tutorial", "sveltekit", "web-development"]
featured: true
---
# My Awesome Blog Post
Your content goes here...
```
### 3. Write Your Content
Use standard Markdown syntax for your blog post content:
```markdown
## Section Heading
This is a paragraph with **bold** and *italic* text.
### Code Examples
```javascript
const example = () => {
console.log('Hello from my blog!');
};
```
### Lists
- Item 1
- Item 2
- Item 3
### Links
Check out [SvelteKit](https://kit.svelte.dev) for more information.
```
## Frontmatter Reference
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Post title displayed in listings and page title |
| `slug` | string | ✅ | URL-friendly identifier for the post |
| `excerpt` | string | ✅ | Short description for listings and meta tags |
| `publishedAt` | string | ✅ | Publication date in YYYY-MM-DD format |
| `author` | string | ✅ | Author name |
| `tags` | array | ✅ | Array of tags for categorization |
| `featured` | boolean | ❌ | Whether to feature this post (default: false) |
## Svelte 5 Integration
### Component-Based Rendering
This implementation uses Svelte 5's component system. Each markdown file is compiled to a Svelte component:
```typescript
export interface BlogPost {
title: string;
slug: string;
excerpt: string;
publishedAt: string;
author: string;
tags: string[];
featured: boolean;
component?: any; // The Svelte component for rendering
}
```
### Rendering Posts
Posts are rendered using `svelte:component`:
```svelte
<!-- In the blog post page -->
{#if post.component}
<svelte:component this={post.component} />
{:else}
<p>Content not available.</p>
{/if}
```
## Blog Utilities
### Available Functions
The blog system provides several utility functions in `src/lib/blog.ts`:
```typescript
// Get all published posts
const posts = await getAllPosts();
// Get a specific post by slug
const post = await getPostBySlug('my-post-slug');
// Get featured posts only
const featured = await getFeaturedPosts();
// Get posts with a specific tag
const tagged = await getPostsByTag('tutorial');
// Get all unique tags
const tags = await getAllTags();
```
### Helper Functions
Additional utilities in `src/lib/blog-utils.ts`:
```typescript
// Generate URL-friendly slug from title
const slug = generateSlug('My Blog Post Title');
// Create a new post template
const template = createPostTemplate('New Post', 'Author Name');
// Validate post metadata
const errors = validatePostMetadata(postData);
// Search posts
const results = searchPosts(allPosts, 'keyword');
// Get related posts
const related = getRelatedPosts(currentPost, allPosts, 3);
```
## Styling and Layout
### Custom Prose Styles
The blog uses Tailwind's typography plugin with custom styling:
```css
.prose {
@apply text-surface-900-50-token max-w-none;
}
.prose h1 {
@apply text-3xl font-bold mb-6;
}
.prose code {
@apply bg-surface-200 dark:bg-surface-700 px-1 py-0.5 rounded;
}
```
### MDX Layout Component
The `MDXLayout.svelte` component provides consistent styling for markdown content and can be customized for your design needs.
## Configuration
### MDSvex Configuration
The markdown processing is configured in `svelte.config.js`:
```javascript
const mdsvexOptions = {
extensions: ['.md'],
layout: {
_: './src/lib/components/MDXLayout.svelte'
},
remarkPlugins: [remarkUnwrapImages, remarkToc, remarkAbbr],
rehypePlugins: [rehypeSlug],
highlight: {
highlighter: async (code, lang) => {
const shiki = await import('shiki');
const highlighter = await shiki.getHighlighter({
themes: ['github-dark', 'github-light'],
langs: ['javascript', 'typescript', 'html', 'css', 'svelte', 'bash', 'json', 'yaml', 'python', 'rust', 'go']
});
const html = highlighter.codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark'
}
});
return html;
}
}
};
```
### Available Plugins
- **remark-unwrap-images**: Removes paragraph wrappers around images
- **remark-toc**: Generates table of contents from headings
- **remark-abbr**: Processes abbreviation definitions
- **rehype-slug**: Adds IDs to headings for anchor links
- **shiki**: Syntax highlighting for code blocks
## SEO Features
### Automatic Meta Tags
Each blog post automatically generates:
- Page title with post title
- Meta description from excerpt
- Open Graph tags for social sharing
- Article metadata (published date, author, tags)
### Example Generated Meta Tags
```html
<title>My Blog Post | Blog</title>
<meta name="description" content="Post excerpt here" />
<meta property="og:title" content="My Blog Post" />
<meta property="og:description" content="Post excerpt here" />
<meta property="og:type" content="article" />
<meta property="article:published_time" content="2024-01-30" />
<meta property="article:author" content="Author Name" />
<meta property="article:tag" content="tutorial" />
```
## Development Workflow
### 1. Create New Post
```bash
# Create new post file
touch src/lib/posts/my-new-post.md
# Add frontmatter and content
# The post will automatically appear in the blog
```
### 2. Preview Posts
Posts are automatically available at:
- Blog index: `/blog`
- Individual post: `/blog/your-post-slug`
### 3. Manage Content
- Posts are automatically sorted by publication date
- Only posts with `publishedAt` dates in the past are shown
- Featured posts appear in a special section
## Advanced Features
### Custom Components in Markdown
You can use Svelte components in your markdown:
```markdown
<script>
import MyComponent from '$lib/components/MyComponent.svelte';
</script>
# My Post
Here's a custom component:
<MyComponent prop="value" />
```
### Dynamic Imports
Posts are loaded dynamically using Vite's `import.meta.glob()`:
```typescript
const allPostFiles = import.meta.glob('/src/lib/posts/*.md');
```
### Build-Time Processing
All markdown processing happens at build time, ensuring:
- Fast page loads
- SEO-friendly static HTML
- No runtime markdown parsing overhead
## Svelte 5 Compatibility
### Key Differences from Previous Versions
1. **Component-based rendering**: Instead of HTML strings, posts are Svelte components
2. **No server-side rendering of content**: Content is rendered client-side as components
3. **Simplified architecture**: No need to handle HTML content strings
### Migration from HTML-based Systems
If migrating from an HTML-content based blog:
1. Update blog post interface to use `component` instead of `content`
2. Replace `{@html post.content}` with `<svelte:component this={post.component} />`
3. Remove any server-side HTML rendering logic
## Deployment
The markdown blog works with any SvelteKit deployment target:
- **Static**: Pre-rendered at build time
- **Server**: Rendered on demand
- **Hybrid**: Mix of static and server rendering
All posts are processed at build time, so they work perfectly with static deployment.
## Best Practices
1. **Consistent Naming**: Use kebab-case for slugs and filenames
2. **Descriptive Excerpts**: Write compelling excerpts for better SEO
3. **Meaningful Tags**: Use consistent, meaningful tags
4. **Proper Dates**: Always use YYYY-MM-DD format for dates
5. **Image Optimization**: Optimize images before including in posts
6. **Internal Linking**: Use relative links for internal content
## Troubleshooting
### Common Issues
1. **Post not appearing**: Check `publishedAt` date is not in the future
2. **Styling issues**: Ensure prose classes are applied correctly
3. **Build errors**: Validate frontmatter YAML syntax
4. **Component not rendering**: Verify the markdown file has proper frontmatter
### Debug Mode
In development, check the browser console for any post loading errors. The system will log issues with individual posts without breaking the entire blog.
### Svelte 5 Specific Issues
- **Component rendering errors**: Check that the markdown files are properly processed by mdsvex
- **Missing syntax highlighting**: Ensure shiki is properly configured
- **Layout issues**: Verify the MDXLayout component is correctly set in svelte.config.js
---
This Svelte 5 compatible markdown blog system provides a powerful, flexible, and maintainable solution for content management while leveraging the full power of Svelte's component system!

36
eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { 'no-undef': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

7854
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "saasy",
"description": "Saasy is a SaaS template for SvelteKit",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@floating-ui/dom": "^1.7.0",
"@lucide/svelte": "^0.511.0",
"@skeletonlabs/skeleton": "^3.1.3",
"@skeletonlabs/skeleton-svelte": "^1.2.3",
"@supabase/supabase-js": "^2.49.8",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"lucide-svelte": "^0.511.0",
"postcss": "^8.5.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"prism-themes": "^1.9.0",
"svelte": "^5.25.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.3",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.6",
"vite-plugin-node-polyfills": "^0.23.0",
"@supabase/ssr": "^0.6.1",
"mdsvex": "^0.12.6",
"rehype-slug": "^6.0.0",
"remark-abbr": "^1.4.2",
"remark-toc": "^9.0.0",
"remark-unwrap-images": "^4.0.1",
"shiki": "^3.4.2"
}
}

19
playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
// playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run dev',
port: 5173, // Default SvelteKit port
reuseExistingServer: !process.env.CI,
},
testDir: 'tests',
use: {
baseURL: 'http://localhost:5173',
},
// projetos: [ // Example for multiple browsers, can be simplified
// { name: 'chromium', use: { browserName: 'chromium' } },
// ],
};
export default config;

35
src/app.css Normal file
View File

@@ -0,0 +1,35 @@
@import 'tailwindcss';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton/optional/presets';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist';
/* Skeleton UI Themes */
@import '@skeletonlabs/skeleton/themes/catppuccin';
@import '@skeletonlabs/skeleton/themes/cerberus';
@import '@skeletonlabs/skeleton/themes/concord';
@import '@skeletonlabs/skeleton/themes/crimson';
@import '@skeletonlabs/skeleton/themes/fennec';
@import '@skeletonlabs/skeleton/themes/hamlindigo';
@import '@skeletonlabs/skeleton/themes/legacy';
@import '@skeletonlabs/skeleton/themes/mint';
@import '@skeletonlabs/skeleton/themes/modern';
@import '@skeletonlabs/skeleton/themes/mona';
@import '@skeletonlabs/skeleton/themes/nosh';
@import '@skeletonlabs/skeleton/themes/nouveau';
@import '@skeletonlabs/skeleton/themes/pine';
@import '@skeletonlabs/skeleton/themes/reign';
@import '@skeletonlabs/skeleton/themes/rocket';
@import '@skeletonlabs/skeleton/themes/rose';
@import '@skeletonlabs/skeleton/themes/sahara';
@import '@skeletonlabs/skeleton/themes/seafoam';
@import '@skeletonlabs/skeleton/themes/terminus';
@import '@skeletonlabs/skeleton/themes/vintage';
@import '@skeletonlabs/skeleton/themes/vox';
@import '@skeletonlabs/skeleton/themes/wintry';
@custom-variant dark (&:where(.dark, .dark *));

24
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
import { SupabaseClient, Session, User } from '@supabase/supabase-js';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient;
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
session: Session | null;
user: User | null;
}
interface PageData {
session: Session | null;
supabase: SupabaseClient;
user: User | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

39
src/app.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
<script>
// Set theme immediately to prevent flashing
(function() {
const colorTheme = localStorage.getItem('colorTheme') || 'skeleton';
const darkMode = localStorage.getItem('darkMode') !== 'false'; // default to true
// Handle dark mode class on html element
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Apply color theme to body when it's available
function applyColorTheme() {
if (document.body) {
document.body.setAttribute('data-theme', colorTheme);
} else {
document.addEventListener('DOMContentLoaded', function() {
document.body.setAttribute('data-theme', colorTheme);
});
}
}
applyColorTheme();
})();
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

87
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,87 @@
import { createServerClient } from '@supabase/ssr';
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
const supabase: Handle = async ({ event, resolve }) => {
/**
* Creates a Supabase client specific to this server request.
*
* The Supabase client is configured to:
* 1. Use cookies for session persistence
* 2. Automatically refresh expired tokens
* 3. Handle server-side authentication
*/
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
}
}
});
/**
* Unlike `supabase.auth.getSession()`, which returns the session _without_
* validating the JWT, this function also calls `getUser()` to validate the
* JWT before returning the session.
*/
event.locals.safeGetSession = async () => {
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
// JWT validation has failed
return { session: null, user: null };
}
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
/**
* Supabase libraries use the `content-range` and `x-supabase-api-version`
* headers, so we need to tell SvelteKit to pass it through.
*/
return name === 'content-range' || name === 'x-supabase-api-version';
}
});
};
const authGuard: Handle = async ({ event, resolve }) => {
const { session, user } = await event.locals.safeGetSession();
event.locals.session = session;
event.locals.user = user;
// Protect routes that require authentication
if (event.url.pathname.startsWith('/app')) {
if (!session) {
throw redirect(303, '/auth/login');
}
}
// Redirect authenticated users away from auth pages
if (event.url.pathname !== "/auth/logout" && event.url.pathname.startsWith('/auth' )) {
if (session) {
throw redirect(303, '/app/dashboard');
}
}
return resolve(event);
};
export const handle: Handle = sequence(supabase, authGuard);

172
src/lib/blog-utils.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { BlogPost } from './blog';
/**
* Utility functions for blog content management
*/
// Generate a URL-friendly slug from a title
export const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.trim()
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
};
// Format date for frontmatter
export const formatDate = (date: Date): string => {
return date.toISOString().split('T')[0];
};
// Create blog post template
export const createPostTemplate = (title: string, author: string = 'Author'): string => {
const slug = generateSlug(title);
const publishedAt = formatDate(new Date());
return `---
title: "${title}"
slug: "${slug}"
excerpt: "Add a compelling excerpt for your blog post here."
publishedAt: "${publishedAt}"
author: "${author}"
tags: ["tag1", "tag2"]
featured: false
---
# ${title}
Your blog post content goes here. You can use all standard Markdown features:
## Headings
### Subheadings
#### Smaller headings
## Lists
- Bullet point 1
- Bullet point 2
- Bullet point 3
1. Numbered item 1
2. Numbered item 2
3. Numbered item 3
## Code Examples
\`\`\`javascript
// JavaScript code example
const example = () => {
console.log('Hello, world!');
};
\`\`\`
## Links and Images
[Link to external site](https://example.com)
## Blockquotes
> This is a blockquote. Use it for highlighting important information or quotes.
## Tables
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Data 1 | Data 2 | Data 3 |
| Data 4 | Data 5 | Data 6 |
## Conclusion
Wrap up your blog post with a compelling conclusion.
`;
};
// Validate blog post metadata
export const validatePostMetadata = (metadata: Partial<BlogPost>): string[] => {
const errors: string[] = [];
if (!metadata.title?.trim()) {
errors.push('Title is required');
}
if (!metadata.slug?.trim()) {
errors.push('Slug is required');
}
if (!metadata.excerpt?.trim()) {
errors.push('Excerpt is required');
}
if (!metadata.publishedAt?.trim()) {
errors.push('Published date is required');
} else {
const date = new Date(metadata.publishedAt);
if (isNaN(date.getTime())) {
errors.push('Published date must be a valid date');
}
}
if (!metadata.author?.trim()) {
errors.push('Author is required');
}
if (!metadata.tags || !Array.isArray(metadata.tags) || metadata.tags.length === 0) {
errors.push('At least one tag is required');
}
return errors;
};
// Search posts by keyword
export const searchPosts = (posts: BlogPost[], keyword: string): BlogPost[] => {
const searchTerm = keyword.toLowerCase();
return posts.filter(post =>
post.title.toLowerCase().includes(searchTerm) ||
post.excerpt.toLowerCase().includes(searchTerm) ||
post.tags.some(tag => tag.toLowerCase().includes(searchTerm)) ||
post.author.toLowerCase().includes(searchTerm)
);
};
// Group posts by year
export const groupPostsByYear = (posts: BlogPost[]): Record<string, BlogPost[]> => {
return posts.reduce((groups, post) => {
const year = new Date(post.publishedAt).getFullYear().toString();
if (!groups[year]) {
groups[year] = [];
}
groups[year].push(post);
return groups;
}, {} as Record<string, BlogPost[]>);
};
// Get related posts based on tags
export const getRelatedPosts = (currentPost: BlogPost, allPosts: BlogPost[], limit: number = 3): BlogPost[] => {
const related = allPosts
.filter(post => post.slug !== currentPost.slug)
.map(post => {
const commonTags = post.tags.filter(tag => currentPost.tags.includes(tag));
return { post, score: commonTags.length };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(item => item.post);
// If we don't have enough related posts, fill with recent posts
if (related.length < limit) {
const recentPosts = allPosts
.filter(post => post.slug !== currentPost.slug)
.filter(post => !related.includes(post))
.slice(0, limit - related.length);
related.push(...recentPosts);
}
return related;
};

85
src/lib/blog.ts Normal file
View File

@@ -0,0 +1,85 @@
import { dev } from '$app/environment';
export interface BlogPost {
title: string;
slug: string;
excerpt: string;
publishedAt: string;
author: string;
tags: string[];
featured: boolean;
}
// Get all blog post files
const allPostFiles = import.meta.glob('/src/lib/posts/*.md');
// Parse frontmatter and extract metadata only
const parsePostMetadata = async (filename: string, module: any): Promise<BlogPost> => {
const postModule = await module();
const { metadata } = postModule;
// Extract slug from filename if not provided in frontmatter
const slug = metadata.slug || filename.split('/').pop()?.replace('.md', '') || '';
return {
title: metadata.title || 'Untitled',
slug,
excerpt: metadata.excerpt || '',
publishedAt: metadata.publishedAt || new Date().toISOString().split('T')[0],
author: metadata.author || 'Anonymous',
tags: metadata.tags || [],
featured: metadata.featured || false
};
};
// Get all posts metadata only
export const getAllPosts = async (): Promise<BlogPost[]> => {
const posts: BlogPost[] = [];
for (const [filename, module] of Object.entries(allPostFiles)) {
try {
const post = await parsePostMetadata(filename, module);
// Only include posts with valid publish dates
if (post.publishedAt && new Date(post.publishedAt) <= new Date()) {
posts.push(post);
}
} catch (error) {
if (dev) {
console.error(`Error loading post ${filename}:`, error);
}
}
}
// Sort by publication date (newest first)
return posts.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
};
// Get a single post metadata by slug
export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => {
const posts = await getAllPosts();
return posts.find(post => post.slug === slug) || null;
};
// Get featured posts metadata only
export const getFeaturedPosts = async (): Promise<BlogPost[]> => {
const posts = await getAllPosts();
return posts.filter(post => post.featured);
};
// Get posts by tag metadata only
export const getPostsByTag = async (tag: string): Promise<BlogPost[]> => {
const posts = await getAllPosts();
return posts.filter(post => post.tags.includes(tag));
};
// Get all unique tags
export const getAllTags = async (): Promise<string[]> => {
const posts = await getAllPosts();
const tagSet = new Set<string>();
posts.forEach(post => {
post.tags.forEach(tag => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { crossfade, scale } from 'svelte/transition';
export let color = 'pink';
// @ts-expect-error - scale is a valid fallback
const [send, receive] = crossfade({ fallback: scale });
let boingers = [
{ val: 1, boinged: true },
{ val: 2, boinged: true },
{ val: 3, boinged: false },
{ val: 4, boinged: true },
{ val: 5, boinged: false }
];
function toggleBoing(id: number) {
const index = boingers.findIndex((v) => v.val === id);
boingers[index].boinged = !boingers[index].boinged;
}
</script>
<div class="container">
<div class="boingers">
{#each boingers.filter((v) => !v.boinged) as { val } (val)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<button
animate:flip
in:receive={{ key: val }}
out:send={{ key: val }}
style="background:{color};"
onclick={() => toggleBoing(val)}>{val}
</button>
{/each}
</div>
<div class="boingers">
{#each boingers.filter((v) => v.boinged) as { val } (val)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<button
animate:flip
in:receive={{ key: val }}
out:send={{ key: val }}
style="background:{color};"
onclick={() => toggleBoing(val)}
>
{val}
</button>
{/each}
</div>
</div>
<style>
.container {
width: 300px;
height: 200px;
display: flex;
justify-content: space-between;
}
.boingers {
display: grid;
grid-template-rows: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
grid-gap: 10px;
}
.boingers button {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
color: #eee;
font-weight: bold;
border-radius: 2px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let {count = 0}: {count: number} = $props();
</script>
<span class="outer">
<button onclick="{() => count = count - 1}">-</button>
<span class="inner">{count}</span>
<button onclick="{() => count = count + 1}">+</button>
</span>
<style>
.outer {
background: darkorange;
height: 20px;
font-size: 12px;
display: inline-flex;
justify-content: space-between;
align-items: center;
transform: translateY(-1px);
margin: 0 5px;
border-radius: 3px;
width: 65px;
box-shadow: 0 3px 15px 1px rgba(0,0,0,0.3)
}
.inner {
margin: 0 0px;
}
button {
height: 20px;
padding: 0px 7px 1px 7px;
margin: 0;
border: none;
background: none;
color: #eee;
font-weight: bold;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,3 @@
# What i wrote last week
Why am i so smart, how is this possible.

View File

@@ -0,0 +1,28 @@
<script lang="ts">
let { children } = $props();
</script>
<div>
{@render children()}
</div>
<style>
div {
background: pink;
border: 23px solid orange;
padding: 0 15px;
width: 400px;
text-align: center;
transform: translateX(-200px);
animation: 2s slide infinite alternate ease-in-out;
}
@keyframes slide {
from {
transform: translateX(-200px);
}
to {
transform: translateX(200px);
}
}
</style>

View File

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Palette } from 'lucide-svelte';
// Available color themes
const colorThemes = [
{ value: 'catppuccin', label: 'Catppuccin' },
{ value: 'cerberus', label: 'Cerberus' },
{ value: 'concord', label: 'Concord' },
{ value: 'crimson', label: 'Crimson' },
{ value: 'fennec', label: 'Fennec' },
{ value: 'hamlindigo', label: 'Hamlindigo' },
{ value: 'legacy', label: 'Legacy' },
{ value: 'mint', label: 'Mint' },
{ value: 'modern', label: 'Modern' },
{ value: 'mona', label: 'Mona' },
{ value: 'nosh', label: 'Nosh' },
{ value: 'nouveau', label: 'Nouveau' },
{ value: 'pine', label: 'Pine' },
{ value: 'reign', label: 'Reign' },
{ value: 'rocket', label: 'Rocket' },
{ value: 'rose', label: 'Rose' },
{ value: 'sahara', label: 'Sahara' },
{ value: 'seafoam', label: 'Seafoam' },
{ value: 'terminus', label: 'Terminus' },
{ value: 'vintage', label: 'Vintage' },
{ value: 'vox', label: 'Vox' },
{ value: 'wintry', label: 'Wintry' }
];
let currentColorTheme = $state('skeleton');
let isDarkMode = $state(true);
let mounted = $state(false);
let showDropdown = $state(false);
// Load theme from localStorage on mount
onMount(() => {
if (browser) {
const savedColorTheme = localStorage.getItem('colorTheme') || 'skeleton';
const savedDarkMode = localStorage.getItem('darkMode') !== 'false'; // default to true
currentColorTheme = savedColorTheme;
isDarkMode = savedDarkMode;
applyTheme(savedColorTheme, savedDarkMode);
mounted = true;
}
});
function applyTheme(colorTheme: string, darkMode: boolean) {
if (browser) {
// Handle dark mode class on html element
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Apply color theme to body
document.body.setAttribute('data-theme', colorTheme);
}
}
function toggleDarkMode() {
isDarkMode = !isDarkMode;
if (browser) {
localStorage.setItem('darkMode', isDarkMode.toString());
applyTheme(currentColorTheme, isDarkMode);
}
}
function selectColorTheme(theme: string) {
currentColorTheme = theme;
showDropdown = false;
if (browser) {
localStorage.setItem('colorTheme', theme);
applyTheme(theme, isDarkMode);
}
}
// Set default theme immediately if not set
$effect(() => {
if (browser && !mounted) {
const savedColorTheme = localStorage.getItem('colorTheme');
const savedDarkMode = localStorage.getItem('darkMode');
if (!savedColorTheme) {
localStorage.setItem('colorTheme', 'skeleton');
}
if (!savedDarkMode) {
localStorage.setItem('darkMode', 'true');
}
if (!savedColorTheme || !savedDarkMode) {
applyTheme(savedColorTheme || 'skeleton', savedDarkMode !== 'false');
}
}
});
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-dropdown')) {
showDropdown = false;
}
}
onMount(() => {
if (browser) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="flex items-center space-x-3">
<!-- Light/Dark Mode Toggle -->
<button
onclick={toggleDarkMode}
class="btn btn-sm preset-outlined-surface-500 flex items-center h-8"
title="Toggle light/dark mode"
aria-label="Toggle light/dark mode"
>
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if !isDarkMode}
<!-- Sun icon for dark mode (click to go light) -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
{:else}
<!-- Moon icon for light mode (click to go dark) -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
{/if}
</svg>
</button>
<!-- Color Theme Selector -->
<div class="theme-dropdown relative">
<button
onclick={() => (showDropdown = !showDropdown)}
class="btn btn-sm preset-outlined-surface-500 h-8 flex items-center"
title="Select color theme"
aria-label="Select color theme"
aria-expanded={showDropdown}
>
<Palette class="size-4" />
<span class="text-sm capitalize">{currentColorTheme}</span>
<svg
class="ml-1 h-3 w-3 transition-transform {showDropdown ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showDropdown}
<div
class="card preset-outlined-primary-500 bg-surface-50-950 absolute left-0 z-50 mt-2 w-48 overflow-hidden rounded-lg shadow-lg"
>
{#each colorThemes as theme}
<button
onclick={() => selectColorTheme(theme.value)}
class="hover:bg-surface-950-50 hover:text-surface-50-950 flex w-full items-center justify-between px-4 py-2 text-left text-sm transition-colors"
>
<span>{theme.label}</span>
{#if currentColorTheme === theme.value}
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>

5
src/lib/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// place files you want to import through the `$lib` alias in this folder.
import { createToaster } from '@skeletonlabs/skeleton-svelte';
export const toaster = createToaster();

View File

@@ -0,0 +1,345 @@
---
title: "Getting Started with Our SaaS Template"
slug: "getting-started-with-our-saas-template"
excerpt: "Discover how our complete SvelteKit & Supabase SaaS template can accelerate your development process from idea to production in minutes."
publishedAt: "2025-05-27"
author: "Luke Hagar"
tags: ["template", "sveltekit", "supabase", "saas", "startup"]
featured: true
---
# Getting Started with Our SaaS Template
Ready to launch your next SaaS project without the months of boilerplate development? Our comprehensive SvelteKit & Supabase template provides everything you need to go from idea to production-ready application in minutes, not months.
## What's Included
Our template comes packed with essential features that every modern SaaS needs:
- **Complete Authentication System** - GitHub OAuth, email/password, and session management
- **Beautiful UI Components** - Built with Skeleton UI and fully responsive
- **Database Integration** - Supabase configured with TypeScript types
- **Pricing & Billing Ready** - [Pricing page](/pricing) structure ready for Stripe integration
- **Blog System** - MDX-powered blog with syntax highlighting
- **Dark/Light Mode** - Multiple theme options for better user experience
- **TypeScript Throughout** - Fully type-safe development experience
## Quick Start Guide
Getting started with our template is incredibly simple:
### Step 1: Clone and Install
```bash
# Clone the repository
git clone https://github.com/your-org/sassy-template.git
cd sassy-template
# Install dependencies
npm install
```
### Step 2: Environment Setup
Create your `.env.local` file with your Supabase credentials:
```env
PUBLIC_SUPABASE_URL=your_supabase_project_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
```
### Step 3: Start Development
```bash
# Start the development server
npm run dev
```
That's it! Your SaaS application is now running locally with:
- Authentication pages at `/auth/signup` and `/auth/login`
- A protected dashboard at `/dashboard`
- [Pricing page](/pricing) ready for customization
- Blog system accessible at `/blog`
## Key Features Walkthrough
### 🔐 Authentication System
Our template includes a complete authentication system powered by Supabase:
```ts
// Example: Checking user session
import { supabase } from '$lib/supabaseClient';
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// User is authenticated
console.log('Welcome,', session.user.email);
}
```
The authentication system includes:
- GitHub OAuth integration (easily extensible to other providers)
- Email/password authentication
- Password reset functionality
- Protected routes with automatic redirects
- Server-side session management
### 🎨 Modern UI with Skeleton
Built on top of Skeleton UI, our template provides:
- 10+ beautiful theme options
- Responsive design out of the box
- Dark/light mode toggle
- Consistent design system
- Accessible components
```svelte
<!-- Example: Using template components -->
<div class="card preset-outlined-surface-200-800 p-8">
<h3 class="h3">Feature Card</h3>
<p class="opacity-75">Beautiful, consistent styling throughout.</p>
</div>
```
### 💳 Pricing Structure
Our [pricing page](/pricing) demonstrates a complete pricing structure with:
- Three-tier pricing model (Starter, Pro, Enterprise)
- Feature comparison tables
- Call-to-action buttons
- FAQ section
- Contact forms for enterprise inquiries
The pricing structure is easily customizable and ready for payment integration:
```typescript
// Example: Pricing configuration
const plans = [
{
name: 'Starter',
price: 0,
features: ['Up to 3 projects', 'Basic analytics', 'Community support']
},
{
name: 'Pro',
price: 19,
features: ['Unlimited projects', 'Advanced analytics', 'Priority support']
}
// ... more plans
];
```
### 📊 Supabase Integration
The template comes with Supabase fully configured:
```typescript
// Real-time subscriptions example
import { supabase } from '$lib/supabaseClient';
const channel = supabase
.channel('realtime-posts')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'posts' },
(payload) => {
console.log('Change received!', payload);
}
)
.subscribe();
```
Features include:
- Row-level security (RLS) policies
- Real-time subscriptions
- File storage configuration
- Database migrations
- TypeScript type generation
### 📝 Blog System
Our MDX-powered blog system supports:
- Markdown with React components
- Syntax highlighting
- SEO optimization
- Tag-based categorization
- Featured posts
```markdown
<!-- Example blog post frontmatter -->
---
title: "Your Blog Post"
slug: "your-blog-post"
excerpt: "A compelling description"
publishedAt: "2024-01-25"
author: "Your Name"
tags: ["saas", "tutorial"]
featured: true
---
```
## Production Deployment
The template is optimized for easy deployment:
### Vercel Deployment
```bash
# Deploy to Vercel
npm install -g vercel
vercel --prod
```
### Environment Variables
Set these in your production environment:
- `PUBLIC_SUPABASE_URL`
- `PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
### Database Setup
1. Create your Supabase project
2. Run the included SQL migrations
3. Configure authentication providers
4. Set up Row Level Security policies
## Customization Examples
### Adding Your Branding
```typescript
// Update src/app.html
<title>Your SaaS Name</title>
<meta name="description" content="Your SaaS description" />
// Customize colors in tailwind.config.js
theme: {
extend: {
colors: {
primary: {
500: '#your-brand-color'
}
}
}
}
```
### Extending the Pricing Model
```typescript
// Add new features to pricing plans
const plans = [
{
name: 'Enterprise Plus',
price: 199,
features: [
'Everything in Enterprise',
'White-label solution',
'Custom integrations',
'Dedicated support team'
]
}
];
```
### Adding New Pages
```svelte
<!-- src/routes/features/+page.svelte -->
<script lang="ts">
import { Database, Zap, Shield } from 'lucide-svelte';
</script>
<div class="container mx-auto py-20">
<h1 class="h1 text-center mb-12">
Powerful <span class="text-primary-500">Features</span>
</h1>
<!-- Your feature content -->
</div>
```
## Performance & Best Practices
Our template follows SvelteKit best practices:
- **Server-Side Rendering (SSR)** for better SEO
- **Progressive Enhancement** for reliability
- **Code Splitting** for optimal loading
- **TypeScript** for developer experience
- **ESLint & Prettier** for code quality
### Monitoring and Analytics
Easy integration with popular services:
```typescript
// Example: Adding analytics
import { browser } from '$app/environment';
import { page } from '$app/stores';
if (browser) {
// Google Analytics 4
gtag('config', 'GA_TRACKING_ID', {
page_title: $page.route.id,
page_location: $page.url.href
});
}
```
## Community and Support
- **GitHub Repository**: Full source code with detailed README
- **Documentation**: Comprehensive guides and examples
- **Community Discord**: Get help from other developers
- **Regular Updates**: New features and security patches
## What's Next?
After setting up the template:
1. **Customize your branding** and color scheme
2. **Configure your database** schema in Supabase
3. **Set up payment processing** with Stripe
4. **Deploy to production** with Vercel or your preferred platform
5. **Add your unique features** using our solid foundation
## Pricing and Licensing
Our template offers flexible options for every stage of your journey:
- **Starter**: Free for personal projects and learning
- **Pro**: Commercial license with priority support
- **Enterprise**: Custom solutions and white-label options
Visit our [pricing page](/pricing) to choose the plan that fits your needs.
## Get Started Today
Ready to accelerate your SaaS development? Our template eliminates months of boilerplate work, letting you focus on what makes your product unique.
```bash
# Get started in under 5 minutes
git clone https://github.com/your-org/sassy-template.git
cd sassy-template
npm install
npm run dev
```
**What you get:**
- ✅ Complete authentication system
- ✅ Beautiful, responsive UI
- ✅ Database integration
- ✅ Payment-ready structure
- ✅ Blog system
- ✅ TypeScript throughout
- ✅ Production deployment guides
Don't spend months building the same features every SaaS needs. Start with our proven template and ship your unique value proposition faster.
[Get Started Now](/auth/signup) • [View Pricing](/pricing) • [Live Demo](/)
---
*Built with ❤️ using SvelteKit, Supabase, and Skeleton UI. Join thousands of developers who trust our template to launch their SaaS projects.*

View File

@@ -0,0 +1,74 @@
---
title: "Svex up your markdown"
slug: "svex-up-your-markdown"
excerpt: "Markdown is pretty good but sometimes you just need more."
publishedAt: "2025-05-27"
author: "Luke Hagar"
color: cadetblue
list: [1, 2, 3, 4, "boo"]
tags: ["mdsvex", "ftw", "rofl"]
---
<script>
import Boinger from '$lib/components/MDSvex/Boinger.svelte';
import Section from '$lib/components/MDSvex/Section.md';
import Count from '$lib/components/MDSvex/Count.svelte';
import Seriously from '$lib/components/MDSvex/Seriously.svelte';
let number = $state(45);
</script>
# { title }
## Good stuff in your markdown
Markdown is pretty good but sometimes you just need more.
Sometimes you need a boinger like this:
<Boinger color="{ color }"/>
Not many people have a boinger right in their markdown.
## Markdown in your markdown
Sometimes what you wrote last week is so good that you just *have* to include it again.
I'm not gonna stand in the way of your egomania.
>
><Section />
> <Count />
>
>— *Me, May 2019*
Yeah, thats right you can put wigdets in markdown (`.svx` files or otherwise). You can put markdown in widgets too.
<Seriously>
### I wasn't joking
```
This is real life
```
</Seriously>
Sometimes you need your widgets **inlined** (like this:<Count count="{number}"/>) because why shouldn't you.
Obviously you have access to values defined in YAML (namespaced under `metadata`) and anything defined in an fenced `js exec` block can be referenced directly.
Normal markdown stuff works too:
| like | this |
|-------|------|
| table | here |
And *this* and **THIS**. And other stuff. You can also use all your favorite Svelte features, like `each` blocks:
<ul>
{#each list as item}
<li>{item}</li>
{/each}
</ul>
and all the other good Svelte stuff.

12
src/lib/supabaseClient.ts Normal file
View File

@@ -0,0 +1,12 @@
// src/lib/supabaseClient.ts
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
if (!PUBLIC_SUPABASE_URL) {
throw new Error("PUBLIC_SUPABASE_URL is required!");
}
if (!PUBLIC_SUPABASE_ANON_KEY) {
throw new Error("PUBLIC_SUPABASE_ANON_KEY is required!");
}
export const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);

108
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { page } from '$app/state';
import { AlertTriangle, Home, RefreshCw, ArrowLeft } from 'lucide-svelte';
$: status = $page.status;
$: message = $page.error?.message;
const getErrorInfo = (status: number) => {
switch (status) {
case 404:
return {
title: 'Page Not Found',
description: 'The page you\'re looking for doesn\'t exist or has been moved.',
icon: AlertTriangle,
color: 'text-warning-500'
};
case 500:
return {
title: 'Server Error',
description: 'Something went wrong on our end. Please try again later.',
icon: AlertTriangle,
color: 'text-error-500'
};
case 403:
return {
title: 'Access Forbidden',
description: 'You don\'t have permission to access this page.',
icon: AlertTriangle,
color: 'text-error-500'
};
default:
return {
title: 'Something went wrong',
description: 'An unexpected error occurred. Please try again.',
icon: AlertTriangle,
color: 'text-error-500'
};
}
};
$: errorInfo = getErrorInfo(status);
</script>
<svelte:head>
<title>Error {status} - Something went wrong</title>
<meta name="description" content="An error occurred while loading the page. Please try again or return to the homepage." />
</svelte:head>
<div class="container mx-auto py-20">
<div class="max-w-2xl mx-auto text-center space-y-8">
<!-- Error Icon and Status -->
<div class="space-y-4">
<svelte:component this={errorInfo.icon} class="size-20 mx-auto {errorInfo.color}" />
<div class="space-y-2">
<h1 class="h1">
<span class="text-7xl font-bold {errorInfo.color}">{status}</span>
</h1>
<h2 class="h2">{errorInfo.title}</h2>
</div>
</div>
<!-- Error Description -->
<div class="card preset-outlined-surface-200-800 p-8 space-y-4">
<p class="text-lg opacity-75">{errorInfo.description}</p>
{#if message}
<div class="card preset-outlined-error-500 p-4">
<p class="text-error-600 dark:text-error-400 text-sm font-mono">{message}</p>
</div>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button
onclick={() => window.history.back()}
class="btn preset-outlined-surface-200-800 flex items-center gap-2"
>
<ArrowLeft class="size-4" />
<span>Go Back</span>
</button>
<button
onclick={() => window.location.reload()}
class="btn preset-outlined-surface-200-800 flex items-center gap-2"
>
<RefreshCw class="size-4" />
<span>Try Again</span>
</button>
<a href="/" class="btn preset-filled-primary-500 flex items-center gap-2">
<Home class="size-4" />
<span>Go Home</span>
</a>
</div>
<!-- Help Section -->
<div class="text-sm opacity-50 space-y-2">
<p>Still having trouble? Here are some helpful links:</p>
<div class="flex items-center justify-center gap-4">
<a href="/blog" class="hover:opacity-75 transition-opacity">Blog</a>
<span></span>
<a href="/contact" class="hover:opacity-75 transition-opacity">Contact Support</a>
<span></span>
<a href="/help" class="hover:opacity-75 transition-opacity">Help Center</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, cookies }) => {
const { session, user } = await locals.safeGetSession();
return {
session,
user,
cookies: cookies.getAll()
};
};

234
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { toaster } from '$lib';
import ThemeSwitch from '$lib/components/ThemeSwitch.svelte';
import { Avatar, Modal, Toaster } from '@skeletonlabs/skeleton-svelte';
import { BookOpen, DollarSign, Home, LayoutDashboard, LogOut, User } from 'lucide-svelte';
import 'prism-themes/themes/prism-vsc-dark-plus.css';
import { onMount } from 'svelte';
import '../app.css';
let { data, children } = $props();
let { session, supabase } = $derived(data);
onMount(() => {
// Sync client-side session with server-side on mount
const { data } = supabase.auth.onAuthStateChange((event, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
invalidate('supabase:auth');
}
});
return () => data.subscription.unsubscribe();
});
</script>
<!-- Skeleton Toasts and Modals -->
<Toaster {toaster}></Toaster>
<Modal />
<!-- Navigation Header -->
<header class="bg-surface-50-950-token border-surface-200-700-token border-b">
<nav class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Left side - Brand and Main navigation -->
<div class="flex items-center space-x-8">
<!-- Brand -->
<a href="/" class="flex items-center gap-2 transition-opacity hover:opacity-75">
<div class="bg-primary-500 flex h-8 w-8 items-center justify-center rounded-lg">
<span class="text-lg font-bold text-white">S</span>
</div>
<span class="text-xl font-bold">Sassy</span>
</a>
<!-- Main Navigation -->
<div class="hidden items-center space-x-1 md:flex">
<a href="/" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
<Home class="size-4" />
<span>Home</span>
</a>
<a href="/pricing" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
<DollarSign class="size-4" />
<span>Pricing</span>
</a>
<a href="/blog" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
<BookOpen class="size-4" />
<span>Blog</span>
</a>
{#if session}
<a href="/dashboard" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
<LayoutDashboard class="size-4" />
<span>Dashboard</span>
</a>
{/if}
</div>
</div>
<!-- Right side - User actions and theme switcher -->
<div class="flex items-center space-x-3">
<ThemeSwitch />
{#if session}
<!-- User Profile Section -->
<div class="border-surface-300-600-token flex items-center gap-3 border-l pl-3">
<Avatar
size="size-8"
src={session.user.user_metadata.avatar_url}
name={session.user.user_metadata.full_name || session.user.email}
/>
<div class="hidden md:block">
<p class="text-sm font-medium">
{session.user.user_metadata.full_name || session.user.email?.split('@')[0]}
</p>
<p class="text-xs opacity-75">{session.user.email}</p>
</div>
<form action="/auth/logout" method="POST" style="display: inline;">
<button
type="submit"
class="btn preset-outlined-surface-200-800 btn-sm flex items-center gap-2"
title="Sign Out"
>
<LogOut class="size-4" />
<span class="hidden sm:inline">Sign Out</span>
</button>
</form>
</div>
{:else}
<!-- Authentication Buttons -->
<div class="flex items-center gap-2">
<a href="/auth" class="btn preset-outlined-surface-500 h-8 flex items-center gap-2">
<User class="size-4" />
<span>Sign In</span>
</a>
<a href="/auth?mode=signup" class="btn preset-filled-primary-500 flex items-center gap-2">
<span>Get Started</span>
</a>
</div>
{/if}
</div>
</div>
<!-- Mobile Navigation Menu (Hidden by default, would need JS to toggle) -->
<div class="border-surface-300-600-token mt-4 border-t pt-4 md:hidden">
<div class="flex flex-col space-y-2">
<a href="/" class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2">
<Home class="size-4" />
<span>Home</span>
</a>
<a
href="/pricing"
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
>
<DollarSign class="size-4" />
<span>Pricing</span>
</a>
<a
href="/blog"
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
>
<BookOpen class="size-4" />
<span>Blog</span>
</a>
{#if session}
<a
href="/dashboard"
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
>
<LayoutDashboard class="size-4" />
<span>Dashboard</span>
</a>
{/if}
</div>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="min-h-screen">
{@render children()}
<!-- Pass session to child pages -->
</main>
<!-- Footer -->
<footer class="bg-surface-100-850-token border-surface-200-700-token mt-20 border-t">
<div class="container mx-auto px-6 py-12">
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<!-- Brand Column -->
<div class="space-y-4">
<div class="flex items-center gap-2">
<div class="bg-primary-500 flex h-8 w-8 items-center justify-center rounded-lg">
<span class="text-lg font-bold text-white">S</span>
</div>
<span class="text-xl font-bold">Sassy</span>
</div>
<p class="text-sm opacity-75">
The complete SvelteKit & Supabase SaaS template. Launch your next project in minutes, not
months.
</p>
</div>
<!-- Product Links -->
<div class="space-y-4">
<h3 class="font-semibold">Product</h3>
<div class="space-y-2 text-sm">
<a href="/pricing" class="block opacity-75 transition-opacity hover:opacity-100"
>Pricing</a
>
<a href="/blog" class="block opacity-75 transition-opacity hover:opacity-100">Blog</a>
<a href="/dashboard" class="block opacity-75 transition-opacity hover:opacity-100"
>Dashboard</a
>
</div>
</div>
<!-- Support Links -->
<div class="space-y-4">
<h3 class="font-semibold">Support</h3>
<div class="space-y-2 text-sm">
<a href="/docs" class="block opacity-75 transition-opacity hover:opacity-100"
>Documentation</a
>
<a href="/contact" class="block opacity-75 transition-opacity hover:opacity-100"
>Contact</a
>
<a href="/help" class="block opacity-75 transition-opacity hover:opacity-100"
>Help Center</a
>
</div>
</div>
<!-- Legal Links -->
<div class="space-y-4">
<h3 class="font-semibold">Legal</h3>
<div class="space-y-2 text-sm">
<a href="/privacy" class="block opacity-75 transition-opacity hover:opacity-100"
>Privacy Policy</a
>
<a href="/terms" class="block opacity-75 transition-opacity hover:opacity-100"
>Terms of Service</a
>
<a href="/cookies" class="block opacity-75 transition-opacity hover:opacity-100"
>Cookie Policy</a
>
</div>
</div>
</div>
<!-- Footer Bottom -->
<div
class="border-surface-300-600-token mt-8 flex flex-col items-center justify-between border-t pt-8 md:flex-row"
>
<p class="text-sm opacity-50">© 2025 Sassy. All rights reserved.</p>
<div class="mt-4 flex items-center gap-4 md:mt-0">
<span class="text-sm opacity-50">Built with</span>
<div class="flex items-center gap-2 text-sm opacity-75">
<span>SvelteKit</span>
<span></span>
<span>Supabase</span>
<span></span>
<span>Skeleton UI</span>
</div>
</div>
</div>
</div>
</footer>

43
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,43 @@
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
/**
* Declare a dependency so the layout can be invalidated, for example, on
* session refresh.
*/
depends('supabase:auth')
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
cookies: {
getAll() {
return data.cookies
},
},
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
const {
data: { user },
} = await supabase.auth.getUser()
return { session, supabase, user }
}

154
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,154 @@
<script lang="ts">
// Icons
import { Rocket, Database, Zap, Star, Users, GitBranch } from 'lucide-svelte';
interface Props {
// Props are passed from the root +layout.svelte
data: any; // Contains session from +layout.server.ts, then updated by +layout.svelte
}
let { data }: Props = $props();
</script>
<div class="container mx-auto py-20 space-y-20">
<!-- Hero Section -->
<header class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-8 items-center">
<div class="order-2 lg:order-1 max-w-3xl space-y-8">
<h1 class="h1">Launch Your SaaS in <span class="text-primary-500">Minutes</span></h1>
<p class="text-2xl opacity-75">
The complete SvelteKit & Supabase template with authentication, payments, and beautiful UI components.
Skip the boilerplate and focus on what matters most.
</p>
<div class="flex flex-col sm:flex-row gap-4">
{#if data.session}
<a href="/app/dashboard" class="btn btn-lg preset-filled-primary-500">
<Rocket class="size-5" />
<span>Go to Dashboard</span>
</a>
{:else}
<a href="/auth/signup" class="btn btn-lg preset-filled-primary-500">
<Star class="size-5" />
<span>Get Started Free</span>
</a>
<a href="/auth/login" class="btn btn-lg preset-outlined-surface-200-800">
<span>Sign In</span>
</a>
{/if}
</div>
</div>
<!-- Hero Image/Graphic -->
<div class="order-1 lg:order-2 flex justify-center">
<div class="w-64 h-64 lg:w-96 lg:h-96 bg-gradient-to-br from-primary-500/20 to-secondary-500/20 rounded-full flex items-center justify-center shadow-2xl">
<Rocket class="size-24 lg:size-32 text-primary-500" />
</div>
</div>
</header>
<hr class="hr max-w-48" />
<!-- Stats Section -->
<section>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 text-center md:text-left">
<div class="space-y-1">
<span class="font-bold text-7xl">99%</span>
<p class="text-primary-500">Faster Development</p>
</div>
<div class="space-y-1">
<span class="font-bold text-7xl">50+</span>
<p class="text-primary-500">Components Ready</p>
</div>
<div class="space-y-1">
<span class="font-bold text-7xl">100%</span>
<p class="text-primary-500">Type Safe</p>
</div>
<div class="space-y-1">
<span class="font-bold text-7xl"></span>
<p class="text-primary-500">Possibilities</p>
</div>
</div>
</section>
<hr class="hr max-w-48" />
<!-- Features Section -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-8">
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
<Database class="stroke-primary-500 size-10" />
<h3 class="h3">Supabase Ready</h3>
<p class="opacity-75">
Authentication, real-time database, and file storage configured out of the box.
Just add your Supabase credentials and you're ready to go.
</p>
</div>
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
<Zap class="stroke-primary-500 size-10" />
<h3 class="h3">Lightning Fast</h3>
<p class="opacity-75">
Built with SvelteKit for maximum performance. Server-side rendering,
optimistic updates, and blazing fast navigation included.
</p>
</div>
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
<GitBranch class="stroke-primary-500 size-10" />
<h3 class="h3">Developer Experience</h3>
<p class="opacity-75">
TypeScript, ESLint, Prettier, and comprehensive documentation.
Built by developers, for developers with love and attention to detail.
</p>
</div>
</section>
<hr class="hr max-w-48" />
<!-- Technologies Section -->
<section class="text-center space-y-8">
<h2 class="h2">Built With Modern Tools</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 items-center opacity-60">
<div class="space-y-2">
<div class="mx-auto w-16 h-16 bg-orange-500 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-xl">S</span>
</div>
<p class="text-sm">SvelteKit</p>
</div>
<div class="space-y-2">
<div class="mx-auto w-16 h-16 bg-green-500 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-xl">S</span>
</div>
<p class="text-sm">Supabase</p>
</div>
<div class="space-y-2">
<div class="mx-auto w-16 h-16 bg-blue-500 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-xl">T</span>
</div>
<p class="text-sm">TypeScript</p>
</div>
<div class="space-y-2">
<div class="mx-auto w-16 h-16 bg-purple-500 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-xl">S</span>
</div>
<p class="text-sm">Skeleton UI</p>
</div>
</div>
</section>
<hr class="hr max-w-48" />
<!-- Call to Action Section -->
<section class="grid grid-cols-1 md:grid-cols-[1fr_auto] items-center gap-4">
<div class="text-center md:text-left space-y-2">
<h3 class="h3">Ready to build your next big idea?</h3>
<p class="opacity-75">Join thousands of developers who trust our template to kickstart their SaaS projects.</p>
</div>
{#if !data.session}
<a href="/auth/signup" class="btn md:btn-lg preset-filled-primary-500">
<Users class="size-5" />
<span>Start Building Today</span>
</a>
{:else}
<a href="/pricing" class="btn md:btn-lg preset-filled-secondary-500">
<Star class="size-5" />
<span>View Pricing</span>
</a>
{/if}
</section>
</div>

View File

@@ -0,0 +1,13 @@
// src/routes/dashboard/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
// The session and authentication check is already handled in hooks.server.ts
// This will only run if the user is authenticated due to the authGuard hook
const { session, user } = await safeGetSession();
return {
session,
user
};
};

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import {
User,
Settings,
CreditCard,
Key,
BarChart,
Shield,
Star,
Users,
Zap
} from 'lucide-svelte';
// The session is managed by the root layout and available through page.data
const { data } = $props();
const session = $derived(data.session);
const user = $derived(session?.user);
// Mock dashboard data - in a real app, this would come from your backend
const dashboardStats = [
{ label: 'Projects', value: '3', icon: BarChart, color: 'text-primary-500' },
{ label: 'API Calls', value: '12.4k', icon: Zap, color: 'text-success-500' },
{ label: 'Users', value: '24', icon: Users, color: 'text-warning-500' },
{ label: 'Uptime', value: '99.9%', icon: Shield, color: 'text-error-500' }
];
const quickActions = [
{
title: 'My Profile',
description: 'View and manage your personal information and application preferences.',
icon: User,
action: 'Go to Profile',
href: '/app/dashboard/profile',
available: false
},
{
title: 'Subscription',
description: 'Check your current plan, billing history, and manage your subscription.',
icon: CreditCard,
action: 'Manage Subscription',
href: '/dashboard/billing',
available: false
},
{
title: 'API Keys',
description: 'Manage your API keys for programmatic access to our services.',
icon: Key,
action: 'Manage Keys',
href: '/dashboard/api-keys',
available: false
},
{
title: 'Usage Analytics',
description: 'View detailed usage statistics and analytics for your account.',
icon: BarChart,
action: 'View Analytics',
href: '/dashboard/analytics',
available: false
},
{
title: 'Settings',
description: 'Configure your account settings, notifications, and preferences.',
icon: Settings,
action: 'Open Settings',
href: '/dashboard/settings',
available: false
}
];
</script>
<svelte:head>
<title>Dashboard - Welcome Back</title>
<meta
name="description"
content="Manage your account, view analytics, and access all your SaaS features from your personalized dashboard."
/>
</svelte:head>
<div class="container mx-auto space-y-20 py-20">
{#if session}
<!-- Welcome Header -->
<header class="mx-auto max-w-3xl space-y-4 text-center">
<h1 class="h1">
Welcome back, <span class="text-primary-500">{user?.user_metadata.full_name || 'User'}</span>!
</h1>
<p class="text-xl opacity-75">
Manage your account, monitor usage, and access all your SaaS features from your personalized
dashboard.
</p>
</header>
<hr class="hr mx-auto max-w-48" />
<!-- Stats Overview -->
<section class="space-y-8">
<h2 class="h2 text-center">Account Overview</h2>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{#each dashboardStats as stat}
<div
class="card preset-outlined-surface-200-800 space-y-4 p-6 text-center transition-all duration-300 hover:scale-105"
>
<stat.icon class={`mx-auto size-8 ${stat.color}`} />
<div class="space-y-1">
<div class="text-3xl font-bold">{stat.value}</div>
<p class="text-sm opacity-75">{stat.label}</p>
</div>
</div>
{/each}
</div>
</section>
<hr class="hr mx-auto max-w-48" />
<!-- Quick Actions -->
<section class="space-y-8">
<h2 class="h2 text-center">Quick Actions</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each quickActions as action}
<div
class="card preset-outlined-surface-200-800 group space-y-4 p-6 transition-all duration-300 hover:scale-105 md:p-8"
>
<action.icon
class="text-primary-500 size-10 transition-transform group-hover:scale-110"
/>
<h3 class="h4 group-hover:text-primary-500 transition-colors">{action.title}</h3>
<p class="text-sm opacity-75">{action.description}</p>
{#if action.available}
<a href={action.href} class="btn preset-filled-primary-500 w-full">
{action.action}
</a>
{:else}
<button class="btn preset-outlined-surface-200-800 w-full opacity-50" disabled>
{action.action} (Coming Soon)
</button>
{/if}
</div>
{/each}
</div>
</section>
<hr class="hr mx-auto max-w-48" />
<!-- Account Status & Upgrade -->
<section class="space-y-8">
<div class="card preset-outlined-primary-500 space-y-6 p-8 text-center">
<div class="flex items-center justify-center gap-2">
<Star class="text-primary-500 size-8" />
<h3 class="h3 text-primary-500">Current Plan: Starter</h3>
</div>
<p class="mx-auto max-w-2xl opacity-75">
You're currently on our free Starter plan. Upgrade to unlock advanced features, higher
limits, and priority support.
</p>
<div class="flex flex-col justify-center gap-4 sm:flex-row">
<a href="/pricing" class="btn preset-filled-primary-500">
<Star class="size-5" />
<span>Upgrade Plan</span>
</a>
<button class="btn preset-outlined-surface-200-800" disabled>
<BarChart class="size-5" />
<span>View Usage</span>
</button>
</div>
</div>
</section>
<!-- Recent Activity (Placeholder) -->
<section class="space-y-8">
<h2 class="h2 text-center">Recent Activity</h2>
<div class="card preset-outlined-surface-200-800 space-y-4 p-8 text-center">
<BarChart class="text-primary-500 mx-auto size-16 opacity-50" />
<h3 class="h4">No recent activity</h3>
<p class="opacity-75">
Start using our services to see your activity here. Create your first project or make an
API call to get started.
</p>
<div class="flex flex-col justify-center gap-4 sm:flex-row">
<button class="btn preset-filled-primary-500" disabled>
Create Project (Coming Soon)
</button>
<button class="btn preset-outlined-surface-200-800" disabled>
View Documentation (Coming Soon)
</button>
</div>
</div>
</section>
{:else if session === null}
<!-- Access Denied -->
<div class="card preset-outlined-error-500 mx-auto max-w-2xl space-y-6 p-8 text-center md:p-12">
<Shield class="text-error-500 mx-auto size-16" />
<h2 class="h3">Access Denied</h2>
<p class="opacity-75">
You need to be logged in to access your dashboard. Please sign in to continue.
</p>
<div class="flex flex-col justify-center gap-4 sm:flex-row">
<a href="/auth/login" class="btn preset-filled-primary-500">
<User class="size-5" />
<span>Sign In</span>
</a>
<a href="/auth/signup" class="btn preset-outlined-surface-200-800">
<span>Create Account</span>
</a>
</div>
</div>
{:else}
<!-- Loading State -->
<div
class="card preset-outlined-surface-200-800 mx-auto max-w-2xl space-y-4 p-8 text-center md:p-12"
>
<div class="border-primary-500 mx-auto h-12 w-12 animate-spin rounded-full border-b-2"></div>
<h3 class="h4">Loading your dashboard...</h3>
<p class="opacity-75">Please wait while we prepare your personalized experience.</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,361 @@
<script lang="ts">
import { supabase } from '$lib/supabaseClient';
import { goto } from '$app/navigation';
import { toaster } from '$lib';
import { Mail, Lock, LogIn, UserPlus, Github, Chrome, MessageCircle, Twitter, Star, Eye, EyeOff } from 'lucide-svelte';
import { page } from '$app/stores';
import { onMount } from 'svelte';
let activeTab = $state('login'); // 'login' or 'signup'
let showPassword = $state(false);
onMount(() => {
// Check URL parameters to set initial tab
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode');
if (mode === 'signup') {
activeTab = 'signup';
}
});
let formData = $state({
email: '',
password: ''
});
let loading = $state(false);
let oauthLoading = $state('');
let message = $state('');
// OAuth providers configuration
const oauthProviders = [
{
name: 'GitHub',
provider: 'github',
icon: Github,
color: 'bg-[#333] hover:bg-[#555] text-white',
description: 'Continue with GitHub'
},
{
name: 'Google',
provider: 'google',
icon: Chrome,
color: 'bg-white hover:bg-gray-50 text-gray-900 border border-gray-300',
description: 'Continue with Google'
},
{
name: 'Discord',
provider: 'discord',
icon: MessageCircle,
color: 'bg-[#5865F2] hover:bg-[#4752C4] text-white',
description: 'Continue with Discord'
},
{
name: 'Twitter',
provider: 'twitter',
icon: Twitter,
color: 'bg-[#1DA1F2] hover:bg-[#1A91DA] text-white',
description: 'Continue with Twitter'
}
];
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
message = '';
try {
if (activeTab === 'login') {
const { error } = await supabase.auth.signInWithPassword({
email: formData.email,
password: formData.password
});
if (error) throw error;
toaster.create({
type: 'info',
title: 'Welcome back!',
description: 'You have been logged in successfully.'
});
} else {
const { data, error } = await supabase.auth.signUp({
email: formData.email,
password: formData.password
});
if (error) throw error;
if (data.session) {
toaster.create({
type: 'info',
title: 'Welcome aboard!',
description: 'Your account has been created and you are now logged in.'
});
} else if (data.user && !data.session) {
toaster.create({
type: 'info',
title: 'Account created successfully!',
description: 'Please check your email to confirm your account.'
});
return; // Don't redirect if email confirmation is needed
}
}
goto('/dashboard');
} catch (error: any) {
toaster.create({
type: 'error',
title: activeTab === 'login' ? 'Login failed' : 'Signup failed',
description: error.message
});
message = error.message;
} finally {
loading = false;
}
}
async function handleOAuth(provider: string) {
oauthLoading = provider;
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: provider as any,
options: {
redirectTo: `${window.location.origin}/dashboard`
}
});
if (error) throw error;
} catch (error: any) {
toaster.create({
type: 'error',
title: `${provider} ${activeTab} failed`,
description: error.message
});
} finally {
oauthLoading = '';
}
}
function switchTab(tab: string) {
activeTab = tab;
message = '';
formData = { email: '', password: '' };
}
</script>
<svelte:head>
<title>{activeTab === 'login' ? 'Sign In' : 'Create Account'} - Sassy</title>
<meta name="description" content={activeTab === 'login'
? 'Sign in to your account to access your dashboard and manage your projects.'
: 'Create your account to start building with our comprehensive SaaS template.'} />
</svelte:head>
<div class="container mx-auto py-20">
<div class="max-w-md mx-auto space-y-8">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
{#if activeTab === 'login'}
<LogIn class="size-8 text-primary-500" />
<h1 class="h1">Welcome <span class="text-primary-500">Back</span></h1>
{:else}
<Star class="size-8 text-primary-500" />
<h1 class="h1">Get <span class="text-primary-500">Started</span></h1>
{/if}
</div>
<p class="text-lg opacity-75">
{#if activeTab === 'login'}
Sign in to your account to access your dashboard and manage your projects.
{:else}
Create your account to start building with our comprehensive SaaS template.
{/if}
</p>
</header>
<!-- Tab Switcher -->
<div class="card preset-outlined-primary-500 p-2">
<div class="flex gap-1">
<button
type="button"
class="btn flex-1 {activeTab === 'login' ? 'preset-filled-primary-500' : 'preset-ghost-primary-500'}"
onclick={() => switchTab('login')}
disabled={loading || oauthLoading !== ''}
>
<LogIn class="size-4" />
Sign In
</button>
<button
type="button"
class="btn flex-1 {activeTab === 'signup' ? 'preset-filled-primary-500' : 'preset-ghost-primary-500'}"
onclick={() => switchTab('signup')}
disabled={loading || oauthLoading !== ''}
>
<UserPlus class="size-4" />
Sign Up
</button>
</div>
</div>
<!-- Auth Form Card -->
<div class="card preset-outlined-primary-500 p-8 space-y-6">
<!-- Error Message -->
{#if message}
<div class="card preset-outlined-error-500 p-4 text-center">
<p class="text-error-600 dark:text-error-400 text-sm">{message}</p>
</div>
{/if}
<!-- Email/Password Form -->
<form onsubmit={handleSubmit} class="space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<label class="label font-medium" for="email">
<Mail class="size-4 inline mr-2" />
Email Address
</label>
<input
class="input preset-outlined-primary-500"
type="email"
id="email"
bind:value={formData.email}
placeholder="Enter your email"
required
disabled={loading || oauthLoading !== ''}
autocomplete="email"
/>
</div>
<div class="space-y-2">
<label class="label font-medium" for="password">
<Lock class="size-4 inline mr-2" />
Password
</label>
<div class="relative">
<input
class="input preset-outlined-primary-500 pr-10"
type={showPassword ? 'text' : 'password'}
id="password"
bind:value={formData.password}
placeholder={activeTab === 'login' ? 'Enter your password' : 'Create a strong password'}
required
disabled={loading || oauthLoading !== ''}
minlength={activeTab === 'signup' ? 6 : undefined}
autocomplete={activeTab === 'login' ? 'current-password' : 'new-password'}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
onclick={() => showPassword = !showPassword}
disabled={loading || oauthLoading !== ''}
>
{#if showPassword}
<EyeOff class="size-4" />
{:else}
<Eye class="size-4" />
{/if}
</button>
</div>
{#if activeTab === 'signup'}
<p class="text-xs opacity-50">Must be at least 6 characters long</p>
{/if}
</div>
</div>
<button
type="submit"
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
disabled={loading || oauthLoading !== ''}
>
{#if loading}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{activeTab === 'login' ? 'Signing you in...' : 'Creating your account...'}
{:else}
{#if activeTab === 'login'}
<LogIn class="size-4" />
Sign In
{:else}
<UserPlus class="size-4" />
Create Account
{/if}
{/if}
</button>
</form>
<!-- Terms Notice for Signup -->
{#if activeTab === 'signup'}
<p class="text-xs opacity-50 text-center">
By creating an account, you agree to our
<a href="/terms" class="text-primary-500 hover:text-primary-600 transition-colors">Terms of Service</a>
and
<a href="/privacy" class="text-primary-500 hover:text-primary-600 transition-colors">Privacy Policy</a>.
</p>
{/if}
<!-- Forgot Password for Login -->
{#if activeTab === 'login'}
<div class="text-center">
<a href="/auth/reset-password" class="text-sm text-primary-500 hover:text-primary-600 transition-colors">
Forgot your password?
</a>
</div>
{/if}
<!-- Divider -->
<div class="flex items-center">
<hr class="flex-grow opacity-30" />
<span class="px-4 text-sm opacity-50">or continue with</span>
<hr class="flex-grow opacity-30" />
</div>
<!-- OAuth Providers -->
<div class="space-y-3">
{#each oauthProviders as provider}
<button
type="button"
class="btn w-full flex items-center justify-center gap-3 {provider.color}"
onclick={() => handleOAuth(provider.provider)}
disabled={loading || oauthLoading !== ''}
>
{#if oauthLoading === provider.provider}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Connecting...
{:else}
<svelte:component this={provider.icon} class="size-4" />
{provider.description}
{/if}
</button>
{/each}
</div>
</div>
<!-- Footer Links -->
<div class="text-center space-y-4">
{#if activeTab === 'login'}
<p class="text-sm opacity-75">
Don't have an account?
<button
type="button"
class="text-primary-500 hover:text-primary-600 transition-colors font-medium"
onclick={() => switchTab('signup')}
>
Create one here
</button>
</p>
{:else}
<p class="text-sm opacity-75">
Already have an account?
<button
type="button"
class="text-primary-500 hover:text-primary-600 transition-colors font-medium"
onclick={() => switchTab('login')}
>
Sign in here
</button>
</p>
{/if}
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
<span></span>
<a href="/terms" class="hover:opacity-75 transition-opacity">Terms of Service</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(302, '/auth');
}

View File

@@ -0,0 +1,10 @@
// src/routes/auth/logout/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ locals: { supabase } }) => {
await supabase.auth.signOut();
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { supabase } from '$lib/supabaseClient';
import { toaster } from '$lib';
import { Mail, Send, ArrowLeft, KeyRound } from 'lucide-svelte';
let email = $state('');
let loading = $state(false);
let sent = $state(false);
async function handleReset(e: Event) {
e.preventDefault();
loading = true;
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/update-password`
});
if (error) throw error;
sent = true;
toaster.create({
type: 'info',
title: 'Reset email sent!',
description: 'Check your email for password reset instructions.'
});
} catch (error: any) {
toaster.create({
type: 'error',
title: 'Reset failed',
description: error.message
});
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Reset Password - Sassy</title>
<meta name="description" content="Reset your password to regain access to your account." />
</svelte:head>
<div class="container mx-auto py-20">
<div class="max-w-md mx-auto space-y-8">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
<KeyRound class="size-8 text-primary-500" />
<h1 class="h1">Reset <span class="text-primary-500">Password</span></h1>
</div>
<p class="text-lg opacity-75">
Enter your email address and we'll send you a link to reset your password.
</p>
</header>
<!-- Reset Form Card -->
<div class="card preset-outlined-primary-500 p-8 space-y-6">
{#if !sent}
<form onsubmit={handleReset} class="space-y-6">
<div class="space-y-2">
<label class="label font-medium" for="email">
<Mail class="size-4 inline mr-2" />
Email Address
</label>
<input
class="input preset-outlined-secondary-500"
type="email"
id="email"
bind:value={email}
placeholder="Enter your email address"
required
disabled={loading}
autocomplete="email"
/>
<p class="text-xs opacity-75">
We'll send password reset instructions to this email address.
</p>
</div>
<button
type="submit"
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
disabled={loading}
>
{#if loading}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending reset email...
{:else}
<Send class="size-4" />
Send Reset Email
{/if}
</button>
</form>
{:else}
<!-- Success State -->
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-success-500 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail class="size-8 text-white" />
</div>
<h2 class="h3">Check your email</h2>
<p class="opacity-75">
We've sent password reset instructions to <strong>{email}</strong>
</p>
<p class="text-sm opacity-50">
Didn't receive the email? Check your spam folder or try again.
</p>
<button
type="button"
class="btn preset-outlined-surface-200-800 w-full"
onclick={() => { sent = false; email = ''; }}
>
Try Different Email
</button>
</div>
{/if}
</div>
<!-- Back to Login -->
<div class="text-center">
<a href="/auth" class="btn preset-ghost-surface-200-800 flex items-center justify-center gap-2">
<ArrowLeft class="size-4" />
Back to Sign In
</a>
</div>
<!-- Help Section -->
<div class="text-center space-y-4">
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
<a href="/contact" class="hover:opacity-75 transition-opacity">Need Help?</a>
<span></span>
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(302, '/auth?mode=signup');
}

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { supabase } from '$lib/supabaseClient';
import { goto } from '$app/navigation';
import { toaster } from '$lib';
import { Lock, Save, Eye, EyeOff, KeyRound } from 'lucide-svelte';
import { onMount } from 'svelte';
let password = $state('');
let confirmPassword = $state('');
let loading = $state(false);
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let session = $state(null);
onMount(async () => {
// Check if user is authenticated (came from reset link)
const { data: { session: currentSession } } = await supabase.auth.getSession();
session = currentSession;
if (!session) {
toaster.create({
type: 'error',
title: 'Invalid reset link',
description: 'Please request a new password reset link.'
});
goto('/auth/reset-password');
}
});
async function handleUpdatePassword(e: Event) {
e.preventDefault();
if (password !== confirmPassword) {
toaster.create({
type: 'error',
title: 'Passwords don\'t match',
description: 'Please ensure both passwords are the same.'
});
return;
}
if (password.length < 6) {
toaster.create({
type: 'error',
title: 'Password too short',
description: 'Password must be at least 6 characters long.'
});
return;
}
loading = true;
try {
const { error } = await supabase.auth.updateUser({
password: password
});
if (error) throw error;
toaster.create({
type: 'info',
title: 'Password updated!',
description: 'Your password has been successfully updated.'
});
goto('/dashboard');
} catch (error: any) {
toaster.create({
type: 'error',
title: 'Update failed',
description: error.message
});
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Update Password - Sassy</title>
<meta name="description" content="Set your new password to secure your account." />
</svelte:head>
<div class="container mx-auto py-20">
<div class="max-w-md mx-auto space-y-8">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
<KeyRound class="size-8 text-primary-500" />
<h1 class="h1">Update <span class="text-primary-500">Password</span></h1>
</div>
<p class="text-lg opacity-75">
Choose a strong password to secure your account.
</p>
</header>
<!-- Update Form Card -->
<div class="card preset-outlined-surface-200-800 p-8 space-y-6">
<form onsubmit={handleUpdatePassword} class="space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<label class="label font-medium" for="password">
<Lock class="size-4 inline mr-2" />
New Password
</label>
<div class="relative">
<input
class="input preset-outlined-surface-200-800 pr-10"
type={showPassword ? 'text' : 'password'}
id="password"
bind:value={password}
placeholder="Create a strong password"
required
disabled={loading}
minlength="6"
autocomplete="new-password"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
onclick={() => showPassword = !showPassword}
disabled={loading}
>
{#if showPassword}
<EyeOff class="size-4" />
{:else}
<Eye class="size-4" />
{/if}
</button>
</div>
<p class="text-xs opacity-50">Must be at least 6 characters long</p>
</div>
<div class="space-y-2">
<label class="label font-medium" for="confirmPassword">
<Lock class="size-4 inline mr-2" />
Confirm New Password
</label>
<div class="relative">
<input
class="input preset-outlined-surface-200-800 pr-10"
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
bind:value={confirmPassword}
placeholder="Confirm your password"
required
disabled={loading}
autocomplete="new-password"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
onclick={() => showConfirmPassword = !showConfirmPassword}
disabled={loading}
>
{#if showConfirmPassword}
<EyeOff class="size-4" />
{:else}
<Eye class="size-4" />
{/if}
</button>
</div>
</div>
</div>
<!-- Password Strength Indicator -->
{#if password.length > 0}
<div class="space-y-2">
<p class="text-sm font-medium">Password Strength:</p>
<div class="flex gap-1">
<div class="h-2 flex-1 rounded {password.length >= 6 ? 'bg-success-500' : 'bg-surface-300'}"></div>
<div class="h-2 flex-1 rounded {password.length >= 8 ? 'bg-success-500' : 'bg-surface-300'}"></div>
<div class="h-2 flex-1 rounded {password.length >= 10 && /[A-Z]/.test(password) && /[0-9]/.test(password) ? 'bg-success-500' : 'bg-surface-300'}"></div>
</div>
<p class="text-xs opacity-50">
{#if password.length < 6}
Weak - Add more characters
{:else if password.length < 8}
Good - Consider adding more characters
{:else if password.length >= 10 && /[A-Z]/.test(password) && /[0-9]/.test(password)}
Strong - Great password!
{:else}
Good - Consider adding uppercase letters and numbers
{/if}
</p>
</div>
{/if}
<!-- Password Match Indicator -->
{#if confirmPassword.length > 0}
<div class="flex items-center gap-2 text-sm">
{#if password === confirmPassword}
<div class="w-2 h-2 bg-success-500 rounded-full"></div>
<span class="text-success-600 dark:text-success-400">Passwords match</span>
{:else}
<div class="w-2 h-2 bg-error-500 rounded-full"></div>
<span class="text-error-600 dark:text-error-400">Passwords don't match</span>
{/if}
</div>
{/if}
<button
type="submit"
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
disabled={loading || password !== confirmPassword || password.length < 6}
>
{#if loading}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Updating password...
{:else}
<Save class="size-4" />
Update Password
{/if}
</button>
</form>
</div>
<!-- Help Section -->
<div class="text-center space-y-4">
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
<a href="/contact" class="hover:opacity-75 transition-opacity">Need Help?</a>
<span></span>
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
// src/routes/blog/+page.server.ts
import { getAllPosts } from '$lib/blog';
export const load = async () => {
try {
const posts = await getAllPosts();
return { posts };
} catch (error) {
console.error('Error loading blog posts:', error);
return { posts: [], error: 'Failed to load blog posts.' };
}
};

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import type { PageData } from './$types';
import type { BlogPost } from '$lib/blog';
import { Calendar, User, Tag, BookOpen, Star } from 'lucide-svelte';
interface Props {
data: PageData & { posts: BlogPost[] };
}
let { data }: Props = $props();
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
</script>
<svelte:head>
<title>Blog - Insights & Tutorials</title>
<meta name="description" content="Discover insights, tutorials, and updates from our team. Learn about SaaS development, best practices, and product updates." />
</svelte:head>
<div class="container mx-auto py-20 space-y-20">
<!-- Header -->
<header class="text-center space-y-4 max-w-3xl mx-auto">
<div class="flex items-center justify-center gap-2 mb-4">
<BookOpen class="size-8 text-primary-500" />
<h1 class="h1">Our <span class="text-primary-500">Blog</span></h1>
</div>
<p class="text-2xl opacity-75">
Insights, tutorials, and updates from our team. Stay up to date with the latest in SaaS development and best practices.
</p>
</header>
{#if data.error}
<div class="card preset-outlined-error-500 p-8 text-center max-w-2xl mx-auto">
<p class="text-error-600 dark:text-error-400">{data.error}</p>
</div>
{:else if data.posts && data.posts.length > 0}
<hr class="hr max-w-48 mx-auto" />
<!-- Featured Posts -->
{@const featuredPosts = data.posts.filter(post => post.featured)}
{#if featuredPosts.length > 0}
<section class="space-y-8">
<div class="flex items-center gap-2">
<Star class="size-6 text-primary-500" />
<h2 class="h2">Featured Posts</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{#each featuredPosts as post}
<article class="card preset-outlined-surface-200-800 p-6 md:p-8 space-y-4 hover:scale-105 transition-all duration-300 group">
<div class="flex items-center justify-between">
<span class="badge preset-filled-primary-500 flex items-center gap-1">
<Star class="size-3" />
Featured
</span>
<div class="flex items-center gap-1 text-sm opacity-75">
<Calendar class="size-4" />
{formatDate(post.publishedAt)}
</div>
</div>
<h3 class="h3 group-hover:text-primary-500 transition-colors">
<a href="/blog/{post.slug}" class="block">
{post.title}
</a>
</h3>
<p class="opacity-75">{post.excerpt}</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<User class="size-4 opacity-50" />
<span class="text-sm opacity-75">{post.author}</span>
</div>
<div class="flex flex-wrap gap-1">
{#each post.tags.slice(0, 2) as tag}
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
<Tag class="size-3" />
{tag}
</span>
{/each}
</div>
</div>
</article>
{/each}
</div>
</section>
<hr class="hr max-w-48 mx-auto" />
{/if}
<!-- All Posts -->
{@const regularPosts = data.posts.filter(post => !post.featured)}
{#if regularPosts.length > 0}
<section class="space-y-8">
<h2 class="h2 text-center">Recent Posts</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each regularPosts as post}
<article class="card preset-outlined-surface-200-800 p-4 md:p-6 space-y-4 hover:scale-105 transition-all duration-300 group">
<div class="flex items-center gap-1 text-sm opacity-75">
<Calendar class="size-4" />
{formatDate(post.publishedAt)}
</div>
<h3 class="h4 group-hover:text-primary-500 transition-colors">
<a href="/blog/{post.slug}" class="block">
{post.title}
</a>
</h3>
<p class="opacity-75 text-sm">{post.excerpt}</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<User class="size-4 opacity-50" />
<span class="text-sm opacity-75">{post.author}</span>
</div>
{#if post.tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each post.tags.slice(0, 3) as tag}
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
<Tag class="size-3" />
{tag}
</span>
{/each}
</div>
{/if}
</div>
</article>
{/each}
</div>
</section>
{/if}
{:else}
<div class="card preset-outlined-surface-200-800 p-8 md:p-12 text-center max-w-2xl mx-auto space-y-4">
<BookOpen class="size-16 mx-auto text-primary-500 opacity-50" />
<h2 class="h3">No blog posts yet</h2>
<p class="opacity-75">
We're working on some great content for you. Check back soon for insights, tutorials, and updates!
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,17 @@
// src/routes/blog/[slug]/+page.server.ts
import { getAllPosts, getPostBySlug } from '$lib/blog';
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
// Get only the metadata for the post
const post = await getPostBySlug(params.slug);
if (!post) {
throw error(404, 'Blog post not found');
}
return {
post,
slug: params.slug // Pass the slug so we can load the component client-side
};
};

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import type { PageData } from './$types';
import type { BlogPost } from '$lib/blog';
import { onMount } from 'svelte';
import { Calendar, User, Tag, Share2, ArrowLeft, Star, Twitter, Linkedin } from 'lucide-svelte';
interface Props {
data: PageData & { post: BlogPost; slug: string };
}
let { data }: Props = $props();
let post = $derived(data.post);
let component: any = $state(null);
let loading = $state(true);
let error = $state(false);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
onMount(async () => {
try {
// Dynamically import the markdown component
const module = await import(`../../../lib/posts/${data.slug}.md`);
component = module.default;
} catch (err) {
console.error('Failed to load blog post component:', err);
error = true;
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>{post.title} | Blog</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:type" content="article" />
<meta property="article:published_time" content={post.publishedAt} />
<meta property="article:author" content={post.author} />
{#each post.tags as tag}
<meta property="article:tag" content={tag} />
{/each}
</svelte:head>
<div class="container mx-auto py-20 space-y-12 max-w-4xl">
<!-- Breadcrumb Navigation -->
<nav class="flex items-center gap-2 text-sm">
<a href="/" class="hover:text-primary-500 transition-colors">Home</a>
<span class="opacity-50">/</span>
<a href="/blog" class="hover:text-primary-500 transition-colors">Blog</a>
<span class="opacity-50">/</span>
<span class="opacity-75">{post.title}</span>
</nav>
<!-- Article Header -->
<header class="space-y-8">
<!-- Featured Badge & Meta Info -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{#if post.featured}
<span class="badge preset-filled-primary-500 flex items-center gap-1">
<Star class="size-3" />
Featured
</span>
{/if}
<div class="flex items-center gap-1 text-sm opacity-75">
<Calendar class="size-4" />
{formatDate(post.publishedAt)}
</div>
</div>
<!-- Back to Blog -->
<a href="/blog" class="btn preset-outlined-surface-200-800 flex items-center gap-2">
<ArrowLeft class="size-4" />
<span>Back to Blog</span>
</a>
</div>
<!-- Title and Excerpt -->
<div class="space-y-4">
<h1 class="h1">{post.title}</h1>
<p class="text-xl opacity-75 leading-relaxed">{post.excerpt}</p>
</div>
<!-- Author and Tags -->
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-2">
<User class="size-5 text-primary-500" />
<span class="font-medium">{post.author}</span>
</div>
<!-- Share Buttons -->
<div class="flex items-center gap-2">
<Share2 class="size-4 opacity-50" />
<span class="text-sm opacity-75 mr-2">Share:</span>
<a
href="https://twitter.com/intent/tweet?text={encodeURIComponent(post.title)}&url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-outlined-surface-200-800"
title="Share on Twitter"
>
<Twitter class="size-4" />
</a>
<a
href="https://www.linkedin.com/sharing/share-offsite/?url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-outlined-surface-200-800"
title="Share on LinkedIn"
>
<Linkedin class="size-4" />
</a>
</div>
</div>
{#if post.tags.length > 0}
<div class="flex items-center gap-2 flex-wrap">
<Tag class="size-4 text-primary-500" />
<span class="text-sm font-medium">Tags:</span>
{#each post.tags as tag}
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
{tag}
</span>
{/each}
</div>
{/if}
</div>
</header>
<hr class="hr" />
<!-- Article Content -->
<article class="prose dark:prose-invert prose-lg max-w-none">
{#if loading}
<div class="card preset-outlined-surface-200-800 p-8 text-center space-y-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mx-auto"></div>
<span class="text-sm opacity-75">Loading article content...</span>
</div>
{:else if error}
<div class="card preset-outlined-error-500 p-8 text-center space-y-4">
<p class="text-error-600 dark:text-error-400">Failed to load blog post content.</p>
<a href="/blog" class="btn preset-filled-primary-500">
<ArrowLeft class="size-4" />
Return to Blog
</a>
</div>
{:else if component}
<div class="prose dark:prose-invert prose-lg max-w-none prose-headings:text-primary-500 prose-links:text-primary-500 prose-code:text-primary-500">
{@render component()}
</div>
{:else}
<div class="card preset-outlined-surface-200-800 p-8 text-center">
<p class="opacity-75">Content not available.</p>
</div>
{/if}
</article>
<hr class="hr" />
<!-- Article Footer -->
<footer class="space-y-8">
<!-- Navigation -->
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<a href="/blog" class="btn preset-filled-primary-500 flex items-center gap-2">
<ArrowLeft class="size-4" />
<span>More Articles</span>
</a>
<!-- Share Section (Repeated for convenience) -->
<div class="flex items-center gap-2">
<span class="text-sm font-medium opacity-75">Share this article:</span>
<a
href="https://twitter.com/intent/tweet?text={encodeURIComponent(post.title)}&url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-outlined-surface-200-800"
>
<Twitter class="size-4" />
<span>Twitter</span>
</a>
<a
href="https://www.linkedin.com/sharing/share-offsite/?url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm preset-outlined-surface-200-800"
>
<Linkedin class="size-4" />
<span>LinkedIn</span>
</a>
</div>
</div>
<!-- Call to Action -->
<div class="card preset-outlined-primary-500 p-8 text-center space-y-4">
<h3 class="h3 text-primary-500">Ready to get started?</h3>
<p class="opacity-75">
Start building your SaaS with our comprehensive template and join thousands of developers.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/auth/signup" class="btn preset-filled-primary-500">
Get Started Free
</a>
<a href="/pricing" class="btn preset-outlined-surface-200-800">
View Pricing
</a>
</div>
</div>
</footer>
</div>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import { Mail, Phone, MapPin, Send, MessageCircle, Clock } from 'lucide-svelte';
import { toaster } from '$lib';
let formData = $state({
name: '',
email: '',
subject: '',
message: ''
});
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toaster.create({
type: 'info',
title: 'Message sent!',
description: 'Thank you for contacting us. We\'ll get back to you soon.'
});
// Reset form
formData = {
name: '',
email: '',
subject: '',
message: ''
};
} catch (error) {
toaster.create({
type: 'error',
title: 'Error',
description: 'Failed to send message. Please try again.'
});
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Contact Us - Get in Touch</title>
<meta name="description" content="Get in touch with our team. We're here to help with any questions about our SaaS platform." />
</svelte:head>
<div class="container mx-auto py-20">
<div class="max-w-6xl mx-auto space-y-16">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
<MessageCircle class="size-8 text-primary-500" />
<h1 class="h1">Get in <span class="text-primary-500">Touch</span></h1>
</div>
<p class="text-xl opacity-75 max-w-2xl mx-auto">
Have questions? We'd love to hear from you. Send us a message and we'll respond as soon as possible.
</p>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
<!-- Contact Information -->
<div class="space-y-8">
<!-- Contact Methods -->
<div class="space-y-6">
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
<Mail class="size-5 text-white" />
</div>
<div>
<h3 class="font-semibold">Email Us</h3>
<p class="text-sm opacity-75">support@sassy.dev</p>
</div>
</div>
</div>
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-secondary-500 rounded-lg flex items-center justify-center">
<Phone class="size-5 text-white" />
</div>
<div>
<h3 class="font-semibold">Call Us</h3>
<p class="text-sm opacity-75">+1 (555) 123-4567</p>
</div>
</div>
</div>
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-tertiary-500 rounded-lg flex items-center justify-center">
<MapPin class="size-5 text-white" />
</div>
<div>
<h3 class="font-semibold">Visit Us</h3>
<p class="text-sm opacity-75">San Francisco, CA</p>
</div>
</div>
</div>
</div>
<!-- Response Time -->
<div class="card preset-filled-primary-500 p-6 text-center">
<Clock class="size-8 mx-auto mb-3" />
<h3 class="h4 mb-2">Quick Response</h3>
<p class="text-sm opacity-90">
We typically respond within 24 hours during business days.
</p>
</div>
</div>
<!-- Contact Form -->
<div class="lg:col-span-2">
<div class="card preset-outlined-surface-200-800 p-8">
<div class="space-y-6">
<div>
<h2 class="h3 mb-2">Send us a message</h2>
<p class="opacity-75">Fill out the form below and we'll get back to you.</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="label font-medium" for="name">
Name *
</label>
<input
class="input preset-outlined-surface-200-800"
type="text"
id="name"
bind:value={formData.name}
placeholder="Your name"
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<label class="label font-medium" for="email">
Email *
</label>
<input
class="input preset-outlined-surface-200-800"
type="email"
id="email"
bind:value={formData.email}
placeholder="your@email.com"
required
disabled={loading}
/>
</div>
</div>
<div class="space-y-2">
<label class="label font-medium" for="subject">
Subject *
</label>
<input
class="input preset-outlined-surface-200-800"
type="text"
id="subject"
bind:value={formData.subject}
placeholder="What can we help you with?"
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<label class="label font-medium" for="message">
Message *
</label>
<textarea
class="textarea preset-outlined-surface-200-800"
id="message"
bind:value={formData.message}
placeholder="Tell us more about your question or feedback..."
rows="6"
required
disabled={loading}
></textarea>
</div>
<button
type="submit"
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
disabled={loading}
>
{#if loading}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
{:else}
<Send class="size-4" />
Send Message
{/if}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- FAQ Section -->
<section class="space-y-8">
<div class="text-center">
<h2 class="h2 mb-4">Frequently Asked <span class="text-primary-500">Questions</span></h2>
<p class="opacity-75">Quick answers to common questions.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
<h3 class="font-semibold">How can I get started?</h3>
<p class="text-sm opacity-75">
Simply sign up for an account and you'll have immediate access to our platform. Check out our pricing page for plan details.
</p>
</div>
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
<h3 class="font-semibold">Do you offer technical support?</h3>
<p class="text-sm opacity-75">
Yes! We provide comprehensive technical support via email and our help center. Premium plans include priority support.
</p>
</div>
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
<h3 class="font-semibold">Can I cancel my subscription anytime?</h3>
<p class="text-sm opacity-75">
Absolutely. You can cancel your subscription at any time from your dashboard. Your access will continue until the end of your billing period.
</p>
</div>
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
<h3 class="font-semibold">Is there a free trial?</h3>
<p class="text-sm opacity-75">
Yes! We offer a 14-day free trial for all new users. No credit card required to get started.
</p>
</div>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
// Icons
import { Check, X, Star, Zap, Crown, Mail } from 'lucide-svelte';
interface Props {
// Assuming session might be useful here, though not directly used in this basic structure
data: any;
}
let { data }: Props = $props();
const plans = [
{
name: 'Starter',
price: 0,
period: 'mo',
description: 'Perfect for side projects and getting started',
features: [
{ name: 'Up to 3 projects', included: true },
{ name: 'Basic analytics', included: true },
{ name: 'Community support', included: true },
{ name: 'Advanced integrations', included: false },
{ name: 'Priority support', included: false },
{ name: 'Custom domains', included: false }
],
popular: false,
buttonText: data.session ? 'Current Plan' : 'Get Started',
buttonClass: 'preset-outlined-surface-200-800'
},
{
name: 'Pro',
price: 19,
period: 'mo',
description: 'Best for growing businesses and teams',
features: [
{ name: 'Unlimited projects', included: true },
{ name: 'Advanced analytics', included: true },
{ name: 'Priority support', included: true },
{ name: 'Advanced integrations', included: true },
{ name: 'Custom domains', included: true },
{ name: 'Team collaboration', included: false }
],
popular: true,
buttonText: data.session ? 'Upgrade to Pro' : 'Start Pro Trial',
buttonClass: 'preset-filled-primary-500'
},
{
name: 'Enterprise',
price: 99,
period: 'mo',
description: 'For large teams with advanced needs',
features: [
{ name: 'Everything in Pro', included: true },
{ name: 'Team collaboration', included: true },
{ name: 'Advanced security', included: true },
{ name: 'Custom integrations', included: true },
{ name: 'Dedicated support', included: true },
{ name: 'SLA guarantee', included: true }
],
popular: false,
buttonText: 'Contact Sales',
buttonClass: 'preset-outlined-primary-500'
}
];
</script>
<svelte:head>
<title>Pricing - Choose Your Plan</title>
<meta name="description" content="Simple, transparent pricing for every stage of your journey. Start free and scale as you grow." />
</svelte:head>
<div class="container mx-auto py-20 space-y-20">
<!-- Header -->
<header class="text-center space-y-4 max-w-3xl mx-auto">
<h1 class="h1">Simple, <span class="text-primary-500">Transparent</span> Pricing</h1>
<p class="text-2xl opacity-75">
Choose the perfect plan for your needs. Start free and scale as you grow.
No hidden fees, no surprises.
</p>
</header>
<hr class="hr max-w-48 mx-auto" />
<!-- Pricing Cards -->
<section class="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
{#each plans as plan}
<div class="card {plan.popular ? 'preset-outlined-primary-500 relative' : 'preset-outlined-secondary-500'} p-6 md:p-8 space-y-6 {plan.popular ? 'scale-105 shadow-2xl' : 'hover:scale-105'} transition-all duration-300">
{#if plan.popular}
<div class="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span class="badge preset-filled-primary-500 flex items-center gap-1">
<Star class="size-3" />
Most Popular
</span>
</div>
{/if}
<!-- Plan Header -->
<div class="text-center space-y-2">
<div class="flex items-center justify-center gap-2">
{#if plan.name === 'Starter'}
<Zap class="size-6 text-primary-500" />
{:else if plan.name === 'Pro'}
<Star class="size-6 text-primary-500" />
{:else}
<Crown class="size-6 text-primary-500" />
{/if}
<h3 class="h3 {plan.popular ? 'text-primary-500' : ''}">{plan.name}</h3>
</div>
<p class="opacity-75 text-sm">{plan.description}</p>
</div>
<!-- Price -->
<div class="text-center space-y-1">
<div class="flex items-baseline justify-center gap-1">
<span class="text-5xl font-bold">${plan.price}</span>
<span class="text-lg opacity-75">/{plan.period}</span>
</div>
{#if plan.price > 0}
<p class="text-sm opacity-50">Billed monthly</p>
{/if}
</div>
<!-- Features -->
<ul class="space-y-3">
{#each plan.features as feature}
<li class="flex items-center gap-3">
{#if feature.included}
<Check class="size-5 text-success-500 flex-shrink-0" />
{:else}
<X class="size-5 text-error-500 flex-shrink-0" />
{/if}
<span class="{feature.included ? '' : 'opacity-50'}">{feature.name}</span>
</li>
{/each}
</ul>
<!-- CTA Button -->
<button class="btn w-full {plan.buttonClass}">
{plan.buttonText}
</button>
</div>
{/each}
</section>
<hr class="hr max-w-48 mx-auto" />
<!-- FAQ Section -->
<section class="max-w-4xl mx-auto space-y-8">
<h2 class="h2 text-center">Frequently Asked Questions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<h3 class="h4 text-primary-500">Can I change plans anytime?</h3>
<p class="opacity-75">
Yes! You can upgrade, downgrade, or cancel your subscription at any time.
Changes take effect on your next billing cycle.
</p>
</div>
<div class="space-y-4">
<h3 class="h4 text-primary-500">Is there a free trial?</h3>
<p class="opacity-75">
Our Starter plan is completely free forever. Pro plans include a 14-day free trial
with no credit card required.
</p>
</div>
<div class="space-y-4">
<h3 class="h4 text-primary-500">What payment methods do you accept?</h3>
<p class="opacity-75">
We accept all major credit cards, PayPal, and bank transfers for Enterprise plans.
All payments are processed securely through Stripe.
</p>
</div>
<div class="space-y-4">
<h3 class="h4 text-primary-500">Do you offer refunds?</h3>
<p class="opacity-75">
Yes! We offer a 30-day money-back guarantee for all paid plans.
No questions asked.
</p>
</div>
</div>
</section>
<hr class="hr max-w-48 mx-auto" />
<!-- Contact Section -->
<section class="grid grid-cols-1 md:grid-cols-[1fr_auto] items-center gap-4 max-w-4xl mx-auto">
<div class="text-center md:text-left space-y-2">
<h3 class="h3">Need a custom solution?</h3>
<p class="opacity-75">
Enterprise teams with specific requirements can get in touch for custom pricing and features.
</p>
</div>
<a href="mailto:sales@example.com" class="btn btn-lg preset-filled-secondary-500 text-surface-50-950">
<Mail class="size-5" />
<span>Contact Sales</span>
</a>
</section>
</div>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { Shield, Calendar, Mail, FileText } from 'lucide-svelte';
const lastUpdated = 'May 27, 2025';
</script>
<svelte:head>
<title>Privacy Policy - How We Protect Your Data</title>
<meta name="description" content="Learn how we collect, use, and protect your personal information in our comprehensive privacy policy." />
</svelte:head>
<aside class="bg-primary-50-950 p-8 text-center">
This privacy policy is a placeholder, and only an example of styling one for the template.
</aside>
<div class="container mx-auto py-20">
<div class="max-w-4xl mx-auto space-y-12">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
<Shield class="size-8 text-primary-500" />
<h1 class="h1">Privacy <span class="text-primary-500">Policy</span></h1>
</div>
<p class="text-xl opacity-75 max-w-2xl mx-auto">
We take your privacy seriously. This policy explains how we collect, use, and protect your personal information.
</p>
<div class="flex items-center justify-center gap-2 text-sm opacity-50">
<Calendar class="size-4" />
<span>Last updated: {lastUpdated}</span>
</div>
</header>
<!-- Content -->
<div class="prose prose-lg max-w-none">
<div class="card text-surface-950-50 p-8 space-y-8">
<section class="space-y-4">
<h2 class="h3 flex items-center gap-2">
<FileText class="size-5 text-primary-500" />
Information We Collect
</h2>
<p class="opacity-75">
We collect information you provide directly to us, such as when you create an account,
update your profile, or contact us for support.
</p>
<div class="space-y-3">
<h4 class="font-semibold h4">Personal Information:</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>Name and email address</li>
<li>Profile information and preferences</li>
<li>Payment and billing information</li>
<li>Communication history with our support team</li>
</ul>
</div>
<div class="space-y-3">
<h4 class="font-semibold h4">Usage Information:</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>Log data and device information</li>
<li>Usage patterns and feature interactions</li>
<li>Performance and error data</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3 flex items-center gap-2">
<Shield class="size-5 text-primary-500" />
How We Use Your Information
</h2>
<p class="opacity-75">
We use the information we collect to provide, maintain, and improve our services.
</p>
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
<li>Provide and operate our SaaS platform</li>
<li>Process transactions and send related information</li>
<li>Send technical notices and support messages</li>
<li>Respond to customer service requests</li>
<li>Improve our services and develop new features</li>
<li>Monitor usage and prevent fraud or abuse</li>
</ul>
</section>
<section class="space-y-4">
<h2 class="h3">Information Sharing</h2>
<p class="opacity-75">
We do not sell, trade, or otherwise transfer your personal information to third parties
except as described in this policy.
</p>
<div class="space-y-3">
<h4 class="font-semibold h4">We may share your information:</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>With service providers who assist our operations</li>
<li>To comply with legal obligations</li>
<li>To protect our rights and prevent fraud</li>
<li>In connection with a business transfer</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Data Security</h2>
<p class="opacity-75">
We implement appropriate security measures to protect your personal information against
unauthorized access, alteration, disclosure, or destruction.
</p>
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
<li>Encryption of data in transit and at rest</li>
<li>Regular security assessments and monitoring</li>
<li>Access controls and authentication measures</li>
<li>Employee training on data protection</li>
</ul>
</section>
<section class="space-y-4">
<h2 class="h3">Your Rights and Choices</h2>
<p class="opacity-75">
You have certain rights regarding your personal information:
</p>
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
<li><strong class="font-semibold text-primary-500">Access:</strong> Request a copy of your personal information</li>
<li><strong class="font-semibold text-primary-500">Update:</strong> Correct inaccurate or incomplete information</li>
<li><strong class="font-semibold text-primary-500">Delete:</strong> Request deletion of your personal information</li>
<li><strong class="font-semibold text-primary-500">Portability:</strong> Receive your data in a portable format</li>
<li><strong class="font-semibold text-primary-500">Opt-out:</strong> Unsubscribe from marketing communications</li>
</ul>
</section>
<section class="space-y-4">
<h2 class="h3">Data Retention</h2>
<p class="opacity-75">
We retain your personal information for as long as necessary to provide our services
and fulfill the purposes outlined in this policy, unless a longer retention period
is required by law.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Cookies and Tracking</h2>
<p class="opacity-75">
We use cookies and similar technologies to enhance your experience, analyze usage,
and provide personalized content.
</p>
<p class="opacity-75">
You can control cookie settings through your browser preferences. However,
disabling cookies may affect the functionality of our service.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">International Data Transfers</h2>
<p class="opacity-75">
Your information may be transferred to and processed in countries other than your own.
We ensure appropriate safeguards are in place to protect your data.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Changes to This Policy</h2>
<p class="opacity-75">
We may update this privacy policy from time to time. We will notify you of any
material changes by posting the new policy on this page and updating the
"last updated" date.
</p>
</section>
</div>
</div>
<!-- Contact Information -->
<div class="card preset-filled-primary-500 p-8 text-center">
<div class="flex items-center justify-center gap-2 mb-4">
<Mail class="size-6" />
<h2 class="h3">Questions About Privacy?</h2>
</div>
<p class="opacity-90 mb-6">
If you have any questions about this privacy policy or our data practices,
please don't hesitate to contact us.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="btn preset-filled-surface-200-800">
Contact Us
</a>
<a href="mailto:privacy@sassy.dev" class="btn preset-ghost-surface-200-800">
privacy@sassy.dev
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,232 @@
<script lang="ts">
import { FileText, Calendar, Mail, Scale } from 'lucide-svelte';
const lastUpdated = 'December 15, 2024';
</script>
<svelte:head>
<title>Terms of Service - User Agreement</title>
<meta name="description" content="Read our terms of service to understand your rights and obligations when using our SaaS platform." />
</svelte:head>
<aside class="bg-primary-50-950 p-8 text-center">
These terms of service are a placeholder, and only an example of styling one for the template.
</aside>
<div class="container mx-auto py-20">
<div class="max-w-4xl mx-auto space-y-12">
<!-- Header -->
<header class="text-center space-y-4">
<div class="flex items-center justify-center gap-2 mb-4">
<Scale class="size-8 text-primary-500" />
<h1 class="h1">Terms of <span class="text-primary-500">Service</span></h1>
</div>
<p class="text-xl opacity-75 max-w-2xl mx-auto">
These terms govern your use of our platform. Please read them carefully before using our services.
</p>
<div class="flex items-center justify-center gap-2 text-sm opacity-50">
<Calendar class="size-4" />
<span>Last updated: {lastUpdated}</span>
</div>
</header>
<!-- Content -->
<div class="prose prose-lg max-w-none">
<div class="card preset-outlined-surface-200-800 p-8 space-y-8">
<section class="space-y-4">
<h2 class="h3 flex items-center gap-2">
<FileText class="size-5 text-primary-500" />
Acceptance of Terms
</h2>
<p class="opacity-75">
By accessing or using our SaaS platform ("Service"), you agree to be bound by these
Terms of Service ("Terms"). If you disagree with any part of these terms, you may not access the Service.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Description of Service</h2>
<p class="opacity-75">
Our platform provides software-as-a-service solutions including but not limited to:
</p>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>User authentication and account management</li>
<li>Data storage and processing capabilities</li>
<li>API access and integrations</li>
<li>Customer support and documentation</li>
</ul>
</section>
<section class="space-y-4">
<h2 class="h3">User Accounts</h2>
<div class="space-y-3">
<h4 class="font-semibold">Account Creation</h4>
<p class="opacity-75">
You must provide accurate and complete information when creating an account.
You are responsible for maintaining the confidentiality of your account credentials.
</p>
<h4 class="font-semibold">Account Responsibility</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>You are responsible for all activities under your account</li>
<li>Notify us immediately of any unauthorized use</li>
<li>Maintain accurate and up-to-date account information</li>
<li>Comply with all applicable laws and regulations</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Acceptable Use</h2>
<p class="opacity-75">
You agree not to use the Service for any unlawful purpose or in any way that could damage,
disable, overburden, or impair the Service.
</p>
<div class="space-y-3">
<h4 class="font-semibold">Prohibited Activities:</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>Violating any applicable laws or regulations</li>
<li>Infringing on intellectual property rights</li>
<li>Transmitting harmful or malicious code</li>
<li>Attempting to gain unauthorized access</li>
<li>Interfering with other users' use of the Service</li>
<li>Using the Service for spam or unsolicited communications</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Payment Terms</h2>
<div class="space-y-3">
<h4 class="font-semibold">Subscription Fees</h4>
<p class="opacity-75">
Subscription fees are billed in advance on a monthly or annual basis.
All fees are non-refundable except as expressly stated in these Terms.
</p>
<h4 class="font-semibold">Payment Processing</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>Payment is due upon subscription or renewal</li>
<li>We may suspend access for overdue payments</li>
<li>Price changes require 30 days advance notice</li>
<li>Taxes are your responsibility unless otherwise stated</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Data and Privacy</h2>
<p class="opacity-75">
We respect your privacy and handle your data in accordance with our Privacy Policy.
By using the Service, you consent to our collection and use of information as described.
</p>
<div class="space-y-3">
<h4 class="font-semibold">Your Data:</h4>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>You retain ownership of your data</li>
<li>We may access data to provide technical support</li>
<li>We implement security measures to protect your data</li>
<li>You can export your data at any time</li>
</ul>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Intellectual Property</h2>
<p class="opacity-75">
The Service and its original content, features, and functionality are owned by us and
are protected by international copyright, trademark, patent, and other intellectual property laws.
</p>
<div class="space-y-3">
<h4 class="font-semibold">License Grant:</h4>
<p class="opacity-75">
We grant you a limited, non-exclusive, non-transferable license to use the Service
for your internal business purposes in accordance with these Terms.
</p>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Service Availability</h2>
<p class="opacity-75">
We strive to maintain high availability but do not guarantee uninterrupted access to the Service.
We may perform maintenance, updates, or modifications that temporarily affect availability.
</p>
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
<li>We aim for 99.9% uptime on our paid plans</li>
<li>Scheduled maintenance will be announced in advance</li>
<li>We provide status updates during outages</li>
</ul>
</section>
<section class="space-y-4">
<h2 class="h3">Termination</h2>
<div class="space-y-3">
<h4 class="font-semibold">By You:</h4>
<p class="opacity-75">
You may terminate your account at any time through your account settings.
Your access will continue until the end of your current billing period.
</p>
<h4 class="font-semibold">By Us:</h4>
<p class="opacity-75">
We may terminate or suspend your account immediately for violations of these Terms,
illegal activities, or non-payment of fees.
</p>
</div>
</section>
<section class="space-y-4">
<h2 class="h3">Limitation of Liability</h2>
<p class="opacity-75">
To the maximum extent permitted by law, we shall not be liable for any indirect,
incidental, special, consequential, or punitive damages arising from your use of the Service.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Indemnification</h2>
<p class="opacity-75">
You agree to indemnify and hold us harmless from any claims, damages, or expenses
arising from your use of the Service or violation of these Terms.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Governing Law</h2>
<p class="opacity-75">
These Terms shall be governed by and construed in accordance with the laws of
[Your Jurisdiction], without regard to its conflict of law provisions.
</p>
</section>
<section class="space-y-4">
<h2 class="h3">Changes to Terms</h2>
<p class="opacity-75">
We reserve the right to modify these Terms at any time. We will notify users of
material changes via email or through the Service. Continued use constitutes acceptance of updated Terms.
</p>
</section>
</div>
</div>
<!-- Contact Information -->
<div class="card preset-filled-primary-500 p-8 text-center">
<div class="flex items-center justify-center gap-2 mb-4">
<Mail class="size-6" />
<h2 class="h3">Questions About These Terms?</h2>
</div>
<p class="opacity-90 mb-6">
If you have any questions about these Terms of Service, please contact our legal team.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" class="btn preset-outlined-surface-200-800">
Contact Us
</a>
<a href="mailto:legal@sassy.dev" class="btn preset-ghost-surface-200-800">
legal@sassy.dev
</a>
</div>
</div>
</div>
</div>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

30
svelte.config.js Normal file
View File

@@ -0,0 +1,30 @@
import adapter from '@sveltejs/adapter-auto';
import { mdsvex } from 'mdsvex';
import remarkUnwrapImages from 'remark-unwrap-images';
import remarkToc from 'remark-toc';
import remarkAbbr from 'remark-abbr';
import rehypeSlug from 'rehype-slug';
/** @type {import('mdsvex').MdsvexOptions} */
const mdsvexOptions = {
extensions: ['.md'],
remarkPlugins: [remarkUnwrapImages, remarkToc, remarkAbbr],
rehypePlugins: [rehypeSlug]
};
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
extensions: ['.svelte', '.md'],
preprocess: [mdsvex(mdsvexOptions)],
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

25
tests/auth.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
test('login page has login form and can attempt interaction', async ({ page }) => {
await page.goto('/auth/login');
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
// We don't expect a successful login without a backend or if Supabase URL isn't configured.
// This test mainly checks if the form fields are present and submittable.
// Further assertions would depend on actual behavior (e.g., error messages).
await page.getByRole('button', { name: 'Login' }).click();
// Add a small wait to see if any client-side error message appears or URL changes
// This is a very basic check. In a real scenario, you'd mock Supabase or check for specific outcomes.
await page.waitForTimeout(1000);
// Example: Check if it stays on the login page (e.g. due to error)
// await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
// Or, if it redirects (less likely here without backend):
// await expect(page).toHaveURL('/dashboard');
});

View File

@@ -0,0 +1,18 @@
// tests/basic-navigation.spec.ts
import { test, expect } from '@playwright/test';
test('homepage has expected title and loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/SvelteKit SaaS Template/); // Assuming a title like this, or adjust
await expect(page.getByRole('heading', { name: 'Your Next SaaS Adventure Starts Here' })).toBeVisible();
});
test('pricing page loads', async ({ page }) => {
await page.goto('/pricing');
await expect(page.getByRole('heading', { name: "Find the Plan That's Right For You" })).toBeVisible();
});
test('blog page loads', async ({ page }) => {
await page.goto('/blog');
await expect(page.getByRole('heading', { name: 'Our Blog' })).toBeVisible();
});

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), nodePolyfills({ protocolImports: true })]
});