feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Deploy Storybook to R2
on:
push:
branches: [storybook-deploy]
paths:
- "packages/storybook/**"
- "packages/ui/**"
- "packages/storybook/package.json"
workflow_dispatch:
env:
NODE_VERSION: "20"
PNPM_VERSION: "8"
jobs:
build-and-deploy-storybook:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm i
- name: Build Storybook
run: |
cd packages/storybook
pnpm run build
- name: Upload Storybook to R2
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
cd packages/storybook
pnpm run upload:storybook
env:
CLOUDFLARE_ACCESS_KEY: ${{ secrets.CLOUDFLARE_ACCESS_KEY }}
CLOUDFLARE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+4
View File
@@ -45,3 +45,7 @@ vite.config.ts*.mjs
.github/instructions/codacy.instructions.md
tsconfig.tsbuildinfo*
storybook-static
.svelte-kit/
+808 -1
View File
@@ -507,6 +507,22 @@ packages/utils/src/validation/
└── README.md ← Documentation
```
**File Naming Convention**:
All file names should use **lowercase with hyphens** (kebab-case):
- ✅ `setup-progress.svelte` (component files)
- ✅ `org.svelte.ts` (API service files)
- ✅ `course.ts` (validation/query files)
- ✅ `user-profile.svelte` (multi-word component files)
- ❌ `SetupProgress.svelte` (PascalCase - reserved for component names in imports)
- ❌ `Org.svelte.ts` (PascalCase)
- ❌ `userProfile.svelte` (camelCase)
- ❌ `user_profile.svelte` (snake_case)
**Note**: Component names in imports can still use PascalCase (e.g., `import SetupProgress from './setup-progress.svelte'`), but the actual file names must be lowercase with hyphens.
**Example: Course Validations.**
Schemas should be **clean** - only validation rules, no error messages:
@@ -768,14 +784,30 @@ if (!result.success) {
```typescript
import { authMiddleware } from '@api/middlewares/auth';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
// Protected route - requires authentication
// Protected route - requires user authentication (session cookies)
.post('/create', authMiddleware, async (c) => {
const user = c.get('user'); // Always available after authMiddleware
const session = c.get('session');
// ...
});
// Protected route - requires API key (server-to-server)
.post('/webhook', apiKeyMiddleware, async (c) => {
// API key validated, proceed
// ...
});
// Protected route - accepts either user session OR API key
.post('/plan', authOrApiKeyMiddleware, async (c) => {
// Can be called by:
// 1. Authenticated users (via session cookies)
// 2. Server-to-server (via API key: Authorization: Bearer <key>)
// ...
});
// Public endpoint - no authentication required
.get('/public-courses', async (c) => {
// Publicly accessible data
@@ -792,6 +824,12 @@ import { authMiddleware } from '@api/middlewares/auth';
});
```
**API Key Authentication:**
- Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
- Use `Authorization: Bearer <api-key>` header format (standard across popular APIs)
- Use `apiKeyMiddleware` for API-key-only endpoints
- Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys
### Complete Route Example
Routes should be thin - they handle HTTP concerns and delegate to services:
@@ -1004,6 +1042,268 @@ export class ProfileApi {
---
### Example: Creating an Organization (POST Route)
We'll create a `POST /organization` route that creates a new organization with the current user as owner.
---
### Step 1: Create Validation Schema
**Location**: `packages/utils/src/validation/organization/organization.ts`
```typescript
import * as z from 'zod';
export const ZCreateOrganization = z.object({
name: z
.string()
.min(5)
.refine((val) => !/^[-]|[-]$/.test(val), {
message: 'Organization name cannot start or end with a hyphen'
}),
siteName: z
.string()
.min(5)
.refine((val) => !/^[-]|[-]$/.test(val), {
message: 'Site name cannot start or end with a hyphen'
})
});
export type TCreateOrganization = z.infer<typeof ZCreateOrganization>;
```
**Export**: Add to `packages/utils/src/validation/organization/index.ts` and `packages/utils/src/validation/index.ts`
---
### Step 2: Create or Reuse Service Function
**Location**: `apps/api/src/services/organization.ts`
**Option A: Reuse Existing Service** (if function exists in another service)
```typescript
import { createOrganizationWithOwner } from '@api/services/onboarding';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Reuse existing function from onboarding service
return await createOrganizationWithOwner(profileId, {
orgName: data.name,
siteName: data.siteName
});
}
```
**Option B: Create New Service** (if you need different logic)
```typescript
import { AppError, ErrorCodes } from '@api/utils/errors';
import { db } from '@cio/db/drizzle';
import {
checkSiteNameExists,
createOrganization,
createOrganizationMember,
getOrganizationByProfileId
} from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(data.siteName);
if (exists) {
throw new AppError(
`Site name '${data.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409,
'siteName'
);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db.transaction(async (tx) => {
const organization = await createOrganization({
name: data.name,
siteName: data.siteName
});
const member = await createOrganizationMember({
organizationId: organization.id,
profileId,
roleId: ROLE.ADMIN,
verified: true
});
return { organization, member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId(profileId);
return {
organization: result.organization,
member: result.member,
organizations
};
} catch (error) {
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(
`Site name '${data.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409,
'siteName'
);
}
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
```
---
### Step 3: Create Route
**Location**: `apps/api/src/routes/organization/organization.ts`
```typescript
import { createOrg } from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { ZCreateOrganization } from '@cio/utils/validation/organization';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
.post('/', authMiddleware, zValidator('json', ZCreateOrganization), async (c) => {
try {
const user = c.get('user');
const data = c.req.valid('json');
const result = await createOrg(user.id, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
});
```
**Note**: Use `201 Created` status code for successful POST requests that create resources.
---
### Step 4: Export and Register Route
```typescript
// apps/api/src/routes/organization/index.ts
export * from './organization';
// apps/api/src/app.ts
import { organizationRouter } from '@api/routes/organization';
export const app = new Hono()
.route('/organization', organizationRouter); // Route available at POST /organization
```
---
### Step 5: Use in Frontend
**Location**: `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
```typescript
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateOrganization } from '@cio/utils/validation/organization';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { orgs, currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
class OrgApi extends BaseApiWithErrors {
async create(fields: { name: string; siteName: string }) {
// Client-side validation
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) return;
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
currentOrg.set(organization);
snackbar.success('Organization created successfully');
this.success = true;
this.errors = {};
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
// Field-specific error automatically mapped to this.errors[field]
this.errors[result.field as string] = result.error;
}
}
});
}
}
export const orgApi = new OrgApi();
```
**Key Points:**
- Use `this.execute()` from `BaseApiWithErrors` for automatic loading/error state
- Field-specific errors are automatically mapped to `this.errors[field]`
- Update stores in `onSuccess` callback
- Handle both validation and API errors
---
### Reusing Existing Service Functions
Sometimes a service function exists in a different context (e.g., `createOrganizationWithOwner` in onboarding service).
**When to Reuse:**
- ✅ The function does exactly what you need
- ✅ It's in the same domain (organization operations)
- ✅ You can call it from your new route without modification
**When to Create New:**
- ❌ The existing function has domain-specific logic you don't want (e.g., onboarding-specific)
- ❌ You need different parameters or return values
- ❌ The function is in a different domain and mixing concerns would be inappropriate
**Example: Reusing a Service**
```typescript
// apps/api/src/services/organization.ts
import { createOrganizationWithOwner } from '@api/services/onboarding';
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
// Reuse existing function, just adapt the parameters
return await createOrganizationWithOwner(profileId, {
orgName: data.name,
siteName: data.siteName
});
}
```
---
### Route Path Convention
When adding routes to an existing router:
@@ -1066,6 +1366,260 @@ When creating a new route, follow this checklist:
---
## Migrating Existing Supabase Calls to API Routes
When you have existing components using direct Supabase calls, follow this migration pattern to move them to the API architecture.
### Migration Workflow
#### Step 1: Identify the Operation
Analyze the existing Supabase call:
- **What database operation?** (CREATE, UPDATE, DELETE, SELECT)
- **What tables are involved?**
- **Are there related operations?** (e.g., create org + create member should be atomic)
- **What validation is currently done?** (client-side only or also server-side?)
**Example Analysis:**
```typescript
// Current code
const { data: org, error } = await supabase
.from('organization')
.insert({ name: orgName, siteName: siteName })
.select();
const { data, error } = await supabase
.from('organizationmember')
.insert({ organization_id: org[0].id, profile_id: $profile.id, role_id: 1 });
// Analysis:
// - Operation: CREATE (organization + member)
// - Tables: organization, organizationmember
// - Related: Yes, should be atomic (transaction)
// - Validation: Client-side only (needs server-side too)
```
#### Step 2: Check for Existing Services
Search for existing service functions that might handle this:
- Check `apps/api/src/services/` for similar operations
- Look in related domains (e.g., onboarding might have org creation)
- Check if the operation exists but in a different context
**Decision:**
- **Reuse existing service** if it does what you need
- **Create new service** if you need different logic or parameters
#### Step 3: Create/Update API Route
If the route doesn't exist:
- Follow the "Creating a New Route" workflow above
- Use appropriate HTTP method: `POST` (create), `PUT` (update), `DELETE` (delete)
If the route exists:
- Add new endpoint to existing router
- Follow route path conventions
#### Step 4: Create Frontend API Method
Add method to existing API class (e.g., `orgApi.create()`):
```typescript
// apps/dashboard/src/lib/features/org/api/org.svelte.ts
class OrgApi extends BaseApiWithErrors {
async create(fields: { name: string; siteName: string }) {
// Client-side validation
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
// Update stores, navigate, show success
if (response.data) {
orgs.set(response.data.organizations);
currentOrg.set(response.data.organization);
}
snackbar.success('Organization created');
this.success = true;
},
onError: (result) => {
// Error handling (automatic for validation errors)
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
```
**Key Points:**
- Always use `this.execute()` from `BaseApiWithErrors`
- Client-side validation before API call
- Update stores in `onSuccess` callback
- Errors automatically handled by `BaseApiWithErrors`
#### Step 5: Update Component
Replace Supabase calls with API class method:
**Before (Direct Supabase):**
```typescript
<script lang="ts">
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
async function createNewOrg() {
// Create organization
const { data: org, error } = await supabase
.from('organization')
.insert({ name: orgName, siteName })
.select();
if (error) {
errors.siteName = error.message;
return;
}
// Create member (separate call - not atomic!)
const { data, error: memberError } = await supabase
.from('organizationmember')
.insert({
organization_id: org[0].id,
profile_id: $profile.id,
role_id: 1
})
.select();
if (memberError) {
// Manual cleanup required
await supabase.from('organization').delete().match({ siteName });
errors.siteName = 'Failed to create member';
return;
}
// Manual store updates
await getOrganizations($profile.id);
goto(`/org/${siteName}`);
}
</script>
```
**After (API Route):**
```typescript
<script lang="ts">
import { orgApi } from '$lib/features/org/api/org.svelte';
import { goto } from '$app/navigation';
async function createNewOrg() {
// Client-side validation (optional, for instant feedback)
errors = createOrgValidation({ orgName, siteName }) as Error;
if (Object.values(errors).length) {
return;
}
// Call API
await orgApi.create({ name: orgName, siteName });
if (orgApi.success) {
// Success - stores already updated by orgApi
goto(`/org/${siteName}`);
$newOrgModal.open = false;
resetForm();
} else {
// Errors automatically mapped to orgApi.errors
if (orgApi.errors.siteName) {
errors.siteName = orgApi.errors.siteName;
} else if (orgApi.errors.general) {
errors.general = orgApi.errors.general;
}
}
}
</script>
```
**Key Differences:**
- ✅ **No manual transaction cleanup** - Handled by service layer
- ✅ **No separate member creation** - Handled atomically by service
- ✅ **Automatic store updates** - Handled by API class `onSuccess`
- ✅ **Consistent error structure** - Field-specific errors automatically mapped
- ✅ **Type-safe** - Full TypeScript support end-to-end
- ✅ **Server-side validation** - Always validated even if client validation passes
### Mapping API Errors to Form Fields
The `BaseApiWithErrors` class automatically maps field-specific errors from the API response:
**API Response Structure:**
```typescript
// Field-specific error
{
success: false,
error: "Sitename already exists",
code: "SITENAME_EXISTS",
field: "siteName" // ← This maps to this.errors.siteName
}
// General error
{
success: false,
error: "Failed to create organization",
code: "ORG_CREATE_FAILED"
// No field property → maps to this.errors.general
}
```
**In Component:**
```typescript
await orgApi.create({ name: orgName, siteName });
if (!orgApi.success) {
// Field-specific error automatically in orgApi.errors.siteName
if (orgApi.errors.siteName) {
errors.siteName = orgApi.errors.siteName;
}
// General error automatically in orgApi.errors.general
if (orgApi.errors.general) {
errors.general = orgApi.errors.general;
}
}
```
**How It Works:**
1. API returns error with `field` property
2. `BaseApiWithErrors.execute()` automatically sets `this.errors[field] = error`
3. Component can access `orgApi.errors.fieldName` directly
4. No manual error mapping needed
### Migration Checklist
When migrating a component from Supabase to API:
- [ ] **Identify operations**: List all Supabase calls in component
- [ ] **Check existing routes**: Search `apps/api/src/routes/` for existing API routes
- [ ] **Check existing services**: Search `apps/api/src/services/` for reusable functions
- [ ] **Create missing pieces**: Follow "Creating a New Route" if route doesn't exist
- [ ] **Create API method**: Add method to appropriate API class using `execute()` pattern
- [ ] **Update component**: Replace Supabase calls with API method
- [ ] **Remove cleanup code**: Delete manual rollback/cleanup (handled by service transactions)
- [ ] **Remove separate calls**: Combine related operations (handled atomically by service)
- [ ] **Update error handling**: Use `orgApi.errors.fieldName` instead of Supabase error structure
- [ ] **Update store sync**: Remove manual store updates (handled by API class `onSuccess`)
- [ ] **Remove Supabase imports**: Delete unused `supabase` imports
- [ ] **Test**: Verify success and error cases work correctly
- [ ] **Test transactions**: Verify atomic operations (e.g., org + member creation)
---
## Step 4: Export and Register Route
### Export Router
@@ -1250,11 +1804,260 @@ Example with **client-side validation** before API call:
</form>
```
### API Abstraction Pattern (API Classes)
When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`, **always use the `execute` method** from `BaseApi` or `BaseApiWithErrors` classes. This provides automatic loading state management, error handling, and consistent response processing.
**Important**:
- **`.svelte.ts` files** are for **client-side use only** (Svelte components). They use Svelte reactivity (`$state`, `$derived`) and manage component state. **Never** include API key logic in these files.
- **`.server.ts` files** are for **server-side use only** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`). They contain static methods with API key authentication and are automatically excluded from client bundles by SvelteKit.
#### Using the `execute` Method
**Pattern:**
```typescript
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
class MyApi extends BaseApiWithErrors {
async updateItem(id: string, data: Partial<Item>) {
await this.execute<typeof classroomio.items[':id']['$put']>({
requestFn: () => classroomio.items[':id'].$put({
param: { id },
json: data
}),
logContext: 'updating item',
onSuccess: (response) => {
// Update local stores with response.data
itemsStore.update((items) => {
const index = items.findIndex((i) => i.id === id);
if (index !== -1 && response.data) {
items[index] = { ...items[index], ...response.data };
}
return items;
});
},
onError: (result) => {
// Optional: Custom error handling
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result) {
this.handleValidationError(result);
}
}
});
}
}
export const myApi = new MyApi();
```
**Key Points:**
- **Always use `this.execute`** - Never call the RPC client directly
- **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety
- **Use `requestFn`** - Wrap the RPC call in a function
- **Use `logContext`** - Provide descriptive context for error logging
- **Use `onSuccess`** - Update local stores/reactivity with response data
- **Use `onError`** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors`)
**Examples:**
```typescript
// Profile API - Simple update
await this.execute<typeof classroomio.account.user.$put>({
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
logContext: 'updating profile',
onSuccess: (response) => {
profileStore.update((_profile) => ({
..._profile,
...response.profile
}));
}
});
// Onboarding API - With error handling
await this.execute<typeof classroomio.onboarding.step1.$post>({
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
logContext: 'submitting organization setup',
onSuccess: (result) => {
const { organizations } = result.data;
orgs.set(organizations);
currentOrg.set(organizations[0]);
this.errors = {};
this.step = ONBOARDING_STEPS.USER_METADATA;
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result) {
this.handleValidationError(result);
}
}
});
```
**Benefits:**
- ✅ Automatic loading state management (`this.isLoading`)
- ✅ Automatic error state management (`this.error` or `this.errors`)
- ✅ Consistent error handling across all API calls
- ✅ Type-safe responses
- ✅ Centralized logging
- ✅ Easy to test and mock
### Server-Side API Calls
**For server-side files** (`+layout.server.ts`, `+page.server.ts`, `+server.ts`), use **`.server.ts` API classes** instead of calling the RPC client directly. This prevents API keys from being accidentally bundled into client-side code.
#### Pattern for Server-Side Files
Create a `.server.ts` file alongside your `.svelte.ts` API class with static methods that use API key authentication:
```typescript
// apps/dashboard/src/lib/features/org/api/org.server.ts
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
}
```
**Usage in server-side files:**
```typescript
// apps/dashboard/src/routes/invite/t/[hash]/+layout.server.ts
import { OrgApiServer } from '$lib/features/org/api/org.server';
export const load = async ({ params }) => {
// Use static method from .server.ts class
const org = await OrgApiServer.getOrgBySiteName(orgSiteName);
return { org };
};
```
#### API Key Authentication for Webhooks/External Calls
For server-to-server communication (webhooks, external integrations), create `.server.ts` files with static methods:
```typescript
// apps/dashboard/src/lib/features/org/api/org-plan.server.ts
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import type { TCreateOrgPlan } from '@cio/utils/validation/organization';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
}
```
**Usage in webhook handlers:**
```typescript
// apps/dashboard/src/routes/api/polar/webhook/+server.ts
import { OrgPlanApiServer } from '$lib/features/org/api/org-plan.server';
// Use static method from .server.ts class
const result = await OrgPlanApiServer.createOrgPlan(planData);
```
#### File Naming Convention
- **`.svelte.ts`**: Client-side API classes (for Svelte components)
- Example: `org.svelte.ts`, `org-plan.svelte.ts`
- Uses Svelte reactivity (`$state`, `$derived`)
- Manages component state and loading/error states
- **Never** contains API key logic
- **`.server.ts`**: Server-side API classes (for server-side files)
- Example: `org.server.ts`, `org-plan.server.ts`
- Contains static methods with API key authentication
- **Only** used in `+layout.server.ts`, `+page.server.ts`, `+server.ts` files
- Automatically excluded from client bundles by SvelteKit
#### API Key Setup
1. Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
2. Set `API_KEY` environment variable in your deployment
3. Use `authOrApiKeyMiddleware` in API routes that accept both user sessions and API keys
4. Create `.server.ts` files with static methods that use `getApiKeyHeaders()` helper
**Helper Function**: Use `getApiKeyHeaders()` from `$lib/utils/services/api/server` to get API key headers. This helper:
- Returns the correct headers format for RPC client
- Validates that `API_KEY` is configured
- Provides consistent API key authentication across all server-side files
#### Why This Pattern?
- ✅ **Security**: API keys are isolated in `.server.ts` files that cannot be imported on the client
- ✅ **Clear separation**: Client-side (`.svelte.ts`) vs server-side (`.server.ts`) is explicit
- ✅ **Type-safe**: Full TypeScript support end-to-end
- ✅ **Consistent**: Same API routes, same validation, same error handling
- ✅ **Maintainable**: Centralized server-side API methods in dedicated files
- ✅ **SvelteKit convention**: `.server.ts` files are automatically excluded from client bundles
### Frontend Best Practices
#### ✅ DO
- **Use type-safe RPC client** for API calls (Hono RPC)
- **Always use `execute` method** in API abstraction classes (never call RPC client directly)
- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts`, `+page.server.ts`, `+server.ts`)
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
- **Use API key** for server-to-server communication (webhooks, external calls)
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
- **Use entity-specific translations** when multiple entities share field names
@@ -1272,6 +2075,10 @@ Example with **client-side validation** before API call:
#### ❌ DON'T
- **Call RPC client directly** in API classes (always use `execute` method)
- **Call RPC client directly in server-side files** - use `.server.ts` API classes instead
- **Use `.svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
- **Include API key logic in `.svelte.ts` files** - API keys must only be in `.server.ts` files
- Skip error handling assuming API calls always succeed
- Display raw API error messages without formatting
- Skip client-side validation (validate before API call for instant feedback)
+3 -1
View File
@@ -9,6 +9,7 @@ import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
import { mailRouter } from '@api/routes/mail';
import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
@@ -26,7 +27,7 @@ export const app = new Hono()
cors({
origin: TRUSTED_ORIGINS,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
@@ -71,6 +72,7 @@ export const app = new Hono()
.route('/account', accountRouter)
.route('/course', courseRouter)
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/organization', organizationRouter)
// Error handling
+3 -1
View File
@@ -6,6 +6,7 @@ const envSchema = z.object({
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
@@ -20,7 +21,8 @@ const envSchema = z.object({
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional(),
TRUSTED_ORIGINS: z.string().optional()
TRUSTED_ORIGINS: z.string().optional(),
SERVER_KEY: z.string().optional()
});
export const env = envSchema.parse(process.env);
+5 -14
View File
@@ -1,20 +1,11 @@
export const ALLOWED_CONTENT_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
// API-specific constants (file size limits)
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
// API-specific constants (bucket names)
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
DOCUMENTS: 'documents',
MEDIA: 'media'
} as const;
+58
View File
@@ -0,0 +1,58 @@
import { Context, Next } from 'hono';
import { env } from '@api/config/env';
/**
* Middleware to authenticate requests using API key
* Checks for Authorization: Bearer <api-key> header
* Used for server-to-server communication (webhooks, etc.)
*/
export const apiKeyMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json(
{
success: false,
message: 'Unauthorized: Missing or invalid API key'
},
401
);
}
const apiKey = authHeader.replace('Bearer ', '').trim();
const expectedApiKey = env.SERVER_KEY;
if (!expectedApiKey) {
console.error('SERVER_KEY not configured in environment');
return c.json(
{
success: false,
message: 'Server configuration error'
},
500
);
}
if (apiKey !== expectedApiKey) {
return c.json(
{
success: false,
message: 'Unauthorized: Invalid API key'
},
401
);
}
// API key is valid, continue
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
@@ -0,0 +1,34 @@
import { Context, Next } from 'hono';
import { apiKeyMiddleware } from './api-key';
import { authMiddleware } from './auth';
/**
* Middleware that accepts either user authentication OR API key
* Used for endpoints that can be accessed by:
* 1. Authenticated users (via session cookies)
* 2. Server-to-server calls (via API key)
*/
export const authOrApiKeyMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');
const session = c.get('session');
if (user && session) {
await next();
return;
}
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
return apiKeyMiddleware(c, next);
}
return c.json(
{
success: false,
message: 'Unauthorized: Authentication required (session or API key)'
},
401
);
};
+62
View File
@@ -0,0 +1,62 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
/**
* Middleware to check if the authenticated user is an admin of the organization
* Requires authMiddleware to be applied first
* Expects orgId in the 'cio-org-id' header
*/
export const orgAdminMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const orgId = c.req.header('cio-org-id');
if (!orgId) {
return c.json(
{
success: false,
error: 'Organization ID is required',
code: 'ORG_ID_REQUIRED'
},
400
);
}
const isAdmin = await isUserOrgAdmin(orgId, user.id);
if (!isAdmin) {
return c.json(
{
success: false,
error: 'Only organization admins can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
await next();
} catch (error) {
console.error('Error in orgAdminMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization admin status',
code: 'ORG_ADMIN_CHECK_FAILED'
},
500
);
}
};
-1
View File
@@ -8,7 +8,6 @@ export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEm
const validatedData = c.req.valid('json');
try {
// Use service to send emails
const results = await deliverEmail(validatedData);
const hasErrors = results.some((result) => !result.success);
+1
View File
@@ -0,0 +1 @@
export * from './media';
+29
View File
@@ -0,0 +1,29 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { uploadImage } from '@api/services/media';
export const mediaRouter = new Hono().post('/image/upload', authMiddleware, async (c) => {
try {
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
throw new AppError(new Error('No file provided'), ErrorCodes.VALIDATION_ERROR, 400);
}
const result = await uploadImage(file);
return c.json({
success: true,
url: result.url,
fileKey: result.fileKey,
message: 'Image uploaded successfully'
});
} catch (error) {
return handleError(c, error, 'Failed to upload image');
}
});
+173 -13
View File
@@ -1,25 +1,39 @@
import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetCoursesBySiteName,
ZGetOrgSetup,
ZGetOrganizationAudience,
ZGetOrganizationTeam,
ZGetOrganizations,
ZUpdateOrgPlan
ZInviteTeamMembers,
ZRemoveTeamMember,
ZUpdateOrgPlan,
ZUpdateOrganization,
ZUpdateOrganizationParam
} from '@cio/utils/validation/organization';
import {
cancelOrgPlan,
createOrg,
createOrgPlan,
getCoursesByOrgSiteName,
getOrgAudience,
getOrgSetupData,
getOrgTeam,
getOrganizationsWithFilters,
inviteTeamMembers,
removeTeamMember,
updateOrg,
updateOrgPlan
} from '@api/services/organization';
import { Hono } from '@api/utils/hono';
import { TOrganization } from '@db/types';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -27,10 +41,12 @@ export const organizationRouter = new Hono()
* GET /organization
* Gets organizations with optional filters
* Query params: siteName (string, optional), customDomain (string, optional), isCustomDomainVerified (boolean, optional)
* Accepts: User session OR API key (for server-side routes)
*/
.get('/', zValidator('query', ZGetOrganizations), async (c) => {
.get('/', authOrApiKeyMiddleware, zValidator('query', ZGetOrganizations), async (c) => {
try {
const filters = c.req.valid('query');
console.log('filters', filters);
const organizations = await getOrganizationsWithFilters(filters);
return c.json(
@@ -41,6 +57,7 @@ export const organizationRouter = new Hono()
200
);
} catch (error) {
console.error('Error fetching organizations:', error);
return handleError(c, error, 'Failed to fetch organizations');
}
})
@@ -65,6 +82,63 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch organization team');
}
})
/**
* POST /organization/:orgId/team/invite
* Invites team members to an organization
* Requires authentication and admin role
*/
.post(
'/:orgId/team/invite',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZGetOrganizationTeam),
zValidator('json', ZInviteTeamMembers),
async (c) => {
try {
const { orgId } = c.req.valid('param');
const { emails, roleId } = c.req.valid('json');
const members = await inviteTeamMembers(orgId, emails, roleId);
return c.json(
{
success: true,
data: members
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to invite team members');
}
}
)
/**
* DELETE /organization/:orgId/team/:memberId
* Removes a team member from an organization
* Requires authentication and admin role
*/
.delete(
'/:orgId/team/:memberId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZRemoveTeamMember),
async (c) => {
try {
const { orgId, memberId } = c.req.valid('param');
await removeTeamMember(orgId, memberId);
return c.json(
{
success: true
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove team member');
}
}
)
/**
* GET /organization/:orgId/audience
* Gets organization audience (students)
@@ -91,28 +165,58 @@ export const organizationRouter = new Hono()
* Gets courses by organization siteName
* Query params: siteName (string)
*/
.get('/courses', zValidator('query', ZGetCoursesBySiteName), async (c) => {
.get(
'/courses',
// TODO: Ratelimit this endpoint
// TODO: Add cache
// TODO: Add pagination
// TODO: Add sorting
// TODO: Add filtering
zValidator('query', ZGetCoursesBySiteName),
async (c) => {
try {
const { siteName } = c.req.valid('query');
const courses = await getCoursesByOrgSiteName(siteName);
return c.json(
{
success: true,
data: courses
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
}
}
)
/**
* GET /organization/setup
* Gets setup data for an organization (courses, lessons, exercises, org info)
* Query params: siteName (string)
*/
.get('/setup', zValidator('query', ZGetOrgSetup), async (c) => {
try {
const { siteName } = c.req.valid('query');
const courses = await getCoursesByOrgSiteName(siteName);
const setupData = await getOrgSetupData(siteName);
return c.json(
{
success: true,
data: courses
data: setupData
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch courses');
return handleError(c, error, 'Failed to fetch setup data');
}
})
/**
* POST /organization/plan
* Creates a new organization plan
* Requires authentication
* Requires authentication (user session or API key)
*/
.post('/plan', authMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
.post('/plan', authOrApiKeyMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
try {
const data = c.req.valid('json');
const plan = await createOrgPlan(data);
@@ -131,9 +235,9 @@ export const organizationRouter = new Hono()
/**
* PUT /organization/plan
* Updates an organization plan by subscription ID
* Requires authentication
* Requires authentication (user session or API key)
*/
.put('/plan', authMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
.put('/plan', authOrApiKeyMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await updateOrgPlan(subscriptionId, payload);
@@ -152,9 +256,9 @@ export const organizationRouter = new Hono()
/**
* POST /organization/plan/cancel
* Cancels an organization plan by subscription ID
* Requires authentication
* Requires authentication (user session or API key)
*/
.post('/plan/cancel', authMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
.post('/plan/cancel', authOrApiKeyMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
try {
const { subscriptionId, payload } = c.req.valid('json');
const plan = await cancelOrgPlan(subscriptionId, payload);
@@ -169,4 +273,60 @@ export const organizationRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to cancel organization plan');
}
});
})
/**
* POST /organization
* Creates a new organization with the current user as owner
* Requires authentication
*/
.post('/', authMiddleware, zValidator('json', ZCreateOrganization), async (c) => {
try {
const user = c.get('user');
const data = c.req.valid('json');
const result = await createOrg(user.id, data);
return c.json(
{
success: true,
data: result
},
201
);
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
})
/**
* PUT /organization/:orgId
* Updates an organization (name, avatarUrl)
* Requires authentication
*/
.put(
'/:orgId',
authMiddleware,
zValidator('param', ZUpdateOrganizationParam),
zValidator('json', ZUpdateOrganization),
async (c) => {
try {
console.log('updateOrg');
console.log(c.req.valid('param'));
console.log(c.req.valid('json'));
const { orgId } = c.req.valid('param');
const data = c.req.valid('json') as Partial<TOrganization>;
const organization = await updateOrg(orgId, data);
return c.json(
{
success: true,
data: organization
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update organization');
}
}
);
+1
View File
@@ -1,2 +1,3 @@
export * from './account';
export * from './media';
export * from './onboarding';
+64
View File
@@ -0,0 +1,64 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { BUCKET_NAME, MAX_IMAGE_SIZE } from '@api/constants/upload';
import { ALLOWED_IMAGE_TYPES } from '@cio/utils/validation';
import { env } from '@api/config/env';
import { generateFileKey } from '@api/utils/upload';
import { uploadToS3 } from '@api/utils/s3';
/**
* Uploads an image file to S3 and returns the public URL
* @param file - The image file to upload
* @returns Object containing the public URL and file key
*/
export async function uploadImage(file: File) {
if (!env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN) {
throw new AppError(new Error('Image bucket domain not configured'), ErrorCodes.INTERNAL_ERROR, 500);
}
// Validate file type
if (!ALLOWED_IMAGE_TYPES.includes(file.type as any)) {
throw new AppError(
new Error(`Invalid file type. Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`),
ErrorCodes.VALIDATION_ERROR,
400
);
}
// Validate file size
if (file.size > MAX_IMAGE_SIZE) {
throw new AppError(
new Error(`File size exceeds maximum of ${MAX_IMAGE_SIZE / 1024 / 1024}MB`),
ErrorCodes.VALIDATION_ERROR,
400
);
}
// Generate unique file key
const fileKey = generateFileKey(file.name);
// Convert File to Buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to S3
const uploadResult = await uploadToS3({
Bucket: BUCKET_NAME.MEDIA,
Key: fileKey,
Body: buffer,
ContentType: file.type,
CacheControl: 'public, max-age=31536000' // Cache for 1 year
});
if (!uploadResult.success) {
throw new AppError(new Error(uploadResult.error || 'Failed to upload image'), ErrorCodes.INTERNAL_ERROR, 500);
}
// Construct public URL
const publicUrl = `${env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN}/${fileKey}`;
return {
url: publicUrl,
fileKey
};
}
+200 -2
View File
@@ -1,15 +1,56 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan, TPlan } from '@db/types';
import {
cancelOrganizationPlan,
checkEmailsExistInOrg,
createOrganizationMembers,
createOrganizationPlan,
deleteOrganizationMember,
getOrganizationAudience,
getOrganizationById,
getOrganizationBySiteName,
getOrganizationTeam,
getOrganizations,
updateOrganization,
updateOrganizationPlan
} from '@cio/db/queries/organization';
import {
getCoursesBySiteName,
getCoursesBySiteNameForSetup,
getExercisesBySiteName,
getLessonsBySiteName
} from '@cio/db/queries/course';
import { getCoursesBySiteName } from '@cio/db/queries/course';
import { createOrganizationWithOwner } from '@api/services/onboarding';
import { env } from '@api/config/env';
import { sendEmail } from '@cio/email';
/**
* Creates a new organization with the current user as owner
* @param profileId - The profile ID of the user creating the organization
* @param data - Organization creation data (name, siteName)
* @returns Created organization, member, and updated organizations list
*/
export async function createOrg(profileId: string, data: { name: string; siteName: string }) {
try {
// Reuse existing function from onboarding service
return await createOrganizationWithOwner(profileId, {
orgName: data.name,
siteName: data.siteName
});
} catch (error) {
// Re-throw AppError as-is
if (error instanceof AppError) {
throw error;
}
// Wrap unexpected errors
throw new AppError(
error instanceof Error ? error.message : 'Failed to create organization',
ErrorCodes.ORG_CREATE_FAILED,
500
);
}
}
/**
* Gets organizations with optional filters
@@ -87,6 +128,44 @@ export async function getCoursesByOrgSiteName(siteName: string) {
}
}
/**
* Gets setup data for an organization
* @param siteName - The organization site name
* @returns Setup data including courses, lessons, exercises, and organization info
*/
export async function getOrgSetupData(siteName: string) {
try {
const [organization, courses, lessons, exercises] = await Promise.all([
getOrganizationBySiteName(siteName),
getCoursesBySiteNameForSetup(siteName),
getLessonsBySiteName(siteName),
getExercisesBySiteName(siteName)
]);
// Check if course is published
const publishedCourse = courses.find((course) => course.isPublished === true);
// Get organization avatar URL directly from organization
const orgHasAvatarUrl = !!organization?.avatarUrl;
return {
isCoursePublished: !!publishedCourse,
isCourseCreated: courses.length > 0,
orgHasAvatarUrl,
courseData: courses,
lessonData: lessons,
isLessonCreated: lessons.length > 0,
isExerciseCreated: exercises.length > 0
};
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch setup data',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Creates a new organization plan
* @param data - Organization plan creation data
@@ -113,6 +192,31 @@ export async function createOrgPlan(data: TNewOrganizationPlan) {
}
}
/**
* Updates an organization
* @param orgId - The organization ID
* @param data - Partial organization data to update
* @returns Updated organization
*/
export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
try {
const organization = await updateOrganization(orgId, data);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
return organization;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update organization',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Updates an organization plan
* @param subscriptionId - Subscription ID
@@ -163,3 +267,97 @@ export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizati
);
}
}
/**
* Invites team members to an organization
* @param orgId - The organization ID
* @param emails - Array of email addresses to invite
* @param roleId - Role ID to assign (ADMIN or TUTOR)
* @returns Array of created members
*/
export async function inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const normalizedEmails = [...new Set(emails.map((email) => email.toLowerCase().trim()))];
const existingEmails = await checkEmailsExistInOrg(orgId, normalizedEmails);
const emailsToInvite = normalizedEmails.filter((email) => !existingEmails.includes(email));
if (emailsToInvite.length === 0) {
return [];
}
try {
const members = await createOrganizationMembers(
emailsToInvite.map((email) => ({
organizationId: orgId,
email,
roleId,
verified: false
}))
);
const baseUrl = env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com';
for (const email of emailsToInvite) {
try {
const inviteData = JSON.stringify({
email,
orgId: organization.id,
orgSiteName: organization.siteName
});
const inviteLink = `${baseUrl}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
await sendEmail('inviteTeacher', {
to: email,
fields: {
email,
orgName: organization.name,
orgSiteName: organization.siteName,
inviteLink
}
});
} catch (emailError) {
console.error(`Failed to send invite email to ${email}:`, emailError);
}
}
return members;
} catch (error) {
console.error('Error inviting team members:', error);
throw new AppError(
error instanceof Error ? error.message : 'Failed to invite team members',
ErrorCodes.ORG_TEAM_INVITE_FAILED,
500
);
}
}
/**
* Removes a team member from an organization
* @param orgId - The organization ID
* @param memberId - The member ID to remove
* @returns Deleted member
*/
export async function removeTeamMember(orgId: string, memberId: number) {
try {
const deleted = await deleteOrganizationMember(orgId, memberId);
if (!deleted) {
throw new AppError('Team member not found', ErrorCodes.ORG_TEAM_REMOVE_FAILED, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove team member',
ErrorCodes.ORG_TEAM_REMOVE_FAILED,
500
);
}
}
+4
View File
@@ -40,6 +40,10 @@ export const ErrorCodes = {
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
+3 -6
View File
@@ -22,11 +22,10 @@ export const s3Client = new S3Client({
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
export async function uploadToS3(params: PutObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
@@ -44,9 +43,7 @@ export async function getFromS3(
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
export async function deleteFromS3(params: DeleteObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
+11 -1
View File
@@ -11,6 +11,13 @@ export function getExtension(filename: string): string {
return ext.length > 1 ? ext[ext.length - 1] : '';
}
export function removeSpacesAndSpecialCharacters(fileName: string): string {
return fileName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9.-]/g, '')
.toLowerCase();
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
@@ -20,5 +27,8 @@ export function generateFileKey(fileName: string): string {
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
const cleanedFileName = removeSpacesAndSpecialCharacters(fileName);
return `${nanoid()}-${cleanedFileName}`;
}
+288
View File
@@ -0,0 +1,288 @@
# Route Page Setup Documentation
This document outlines the pattern for setting up page routes in the dashboard application. Follow this structure to maintain consistency across the codebase.
## Overview
The application follows a feature-based architecture where:
- **Route files** (`src/routes/org/[slug]/[feature]/+page.svelte`) are minimal wrappers that set up the Page structure
- **Feature pages** (`src/lib/features/[feature]/pages/[feature].svelte`) contain all business logic, state management, and UI components
- **Components** are organized in `src/lib/features/[feature]/components/` and exported via `index.ts`
- **Pages** are exported from `src/lib/features/[feature]/pages/index.ts`
## Directory Structure
```bash
apps/dashboard/src/
├── routes/
│ └── org/
│ └── [slug]/
│ └── [feature]/
│ └── +page.svelte # Minimal route wrapper
└── lib/
└── features/
└── [feature]/
├── components/
│ ├── [component].svelte
│ └── index.ts # Export all components
├── pages/
│ ├── [feature].svelte # Main feature page with all logic
│ └── index.ts # Export page component
├── utils/ # Feature-specific utilities
└── api/ # Feature-specific API calls
```
## Route File Pattern (`+page.svelte`)
The route file should be minimal and only contain:
1. **Page structure** using `@cio/ui/base/page` components
2. **Import and render** the feature page component
3. **Page metadata** (title, etc.)
### Example Route File
```svelte
<script>
import { FeaturePage } from '$lib/features/[feature]/pages';
import { FeatureActionButton } from '$lib/features/[feature]/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Feature Name - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('feature.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<FeatureActionButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<FeaturePage />
{/snippet}
</Page.Body>
</Page.Root>
```
### Route File Rules
- ✅ DO: Keep it minimal - only Page structure
- ✅ DO: Import feature page from `$lib/features/[feature]/pages`
- ✅ DO: Import action buttons/components from `$lib/features/[feature]/components`
- ✅ DO: Use `Page.Root`, `Page.Header`, `Page.HeaderContent`, `Page.Title`, `Page.Action`, `Page.Body`
- ❌ DON'T: Put business logic in route files
- ❌ DON'T: Put state management in route files
- ❌ DON'T: Put data fetching in route files
- ❌ DON'T: Put filters/search in route files (use `Page.BodyHeader` in feature page)
## Feature Page Pattern (`pages/[feature].svelte`)
The feature page contains all the business logic, state management, and UI components.
### Feature Page Structure
```svelte
<script lang="ts">
// 1. UI Component Imports
import { Empty } from '@cio/ui/custom/empty';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import { SomeIcon } from '@lucide/svelte/icons/...';
// 2. Feature Component Imports
import { FeatureCard, FeatureLoader, FeatureModal } from '../components';
// 3. Utility Imports
import { t } from '$lib/utils/functions/translations';
import { someStore } from '../utils/store';
import { fetchFeatureData } from '../api';
// 4. State Management
let isLoading = $state(false);
let searchValue = $state('');
let selectedId = $state('');
</script>
<FeatureModal />
<Page.BodyHeader align="right">
<Search placeholder="Search..." bind:value={searchValue} />
<Select.Root bind:value={selectedId}>
<!-- Select options -->
</Select.Root>
</Page.BodyHeader>
<!-- Main Content with States -->
<div class="w-full">
{#if isLoading}
<!-- Loading State -->
<FeatureLoader />
<FeatureLoader />
<FeatureLoader />
{:else if !data.length}
<!-- Empty State -->
<Empty
title={$t('feature.empty_title')}
description={$t('feature.empty_description')}
icon={SomeIcon}
variant="page"
>
<FeatureActionButton />
</Empty>
{:else}
<!-- Content -->
{#each data as item}
<FeatureCard {item} />
{/each}
{/if}
</div>
```
### Feature Page Rules
- ✅ DO: Use `Page.BodyHeader` for filters, search, and view toggles
- ✅ DO: Use `Empty` component from `@cio/ui/custom/empty` for empty states
- ✅ DO: Use `Search` component from `@cio/ui/custom/search` for search inputs
- ✅ DO: Handle loading states with skeleton/loader components
- ✅ DO: Export the page component from `pages/index.ts` as `[Feature]Page`
- ✅ DO: Keep all business logic in the feature page
- ❌ DON'T: Use `Page.Header` or `Page.Action` in feature pages (these are in route files)
- ❌ DON'T: Put route-specific logic in feature pages
## Component Organization
### Components Directory
Create reusable components specific to the feature:
```bash
components/
├── feature-card.svelte
├── feature-loader.svelte
├── feature-modal.svelte
└── index.ts
```
### Components Index (`components/index.ts`)
```typescript
export { default as FeatureCard } from './feature-card.svelte';
export { default as FeatureLoader } from './feature-loader.svelte';
export { default as FeatureModal } from './feature-modal.svelte';
export { default as FeatureActionButton } from './feature-action-button.svelte';
```
## Pages Index (`pages/index.ts`)
```typescript
export { default as FeaturePage } from './feature.svelte';
```
## Complete Example
### Route File: `routes/org/[slug]/community/+page.svelte`
```svelte
<script>
import { CommunityPage } from '$lib/features/community/pages';
import { AskCommunityButton } from '$lib/features/community/components';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<AskCommunityButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CommunityPage />
{/snippet}
</Page.Body>
</Page.Root>
```
### Feature Page: `lib/features/community/pages/community.svelte`
```svelte
<script lang="ts">
import { Search } from '@cio/ui/custom/search';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import { CommunityListLoader, AskCommunityButton } from '../components';
// ... other imports
let isLoading = $state(false);
let searchValue = $state('');
let discussions = $state([]);
// ... logic
</script>
<Page.BodyHeader>
<Search placeholder="Search..." bind:value={searchValue} />
<Select.Root>
<!-- Select options -->
</Select.Root>
</Page.BodyHeader>
{#if isLoading}
<CommunityListLoader />
{:else}
{#each discussions as discussion}
<!-- Discussion content -->
{:else}
<Empty
title={$t('community.no_question')}
description={$t('community.ask_a_question')}
icon={MessageSquareMoreIcon}
>
<AskCommunityButton />
</Empty>
{/each}
{/if}
```
## Key Patterns
1. **Separation of Concerns**: Route files handle routing structure, feature pages handle business logic
2. **Reusability**: Feature pages can be imported and used in different contexts
3. **Consistency**: All pages follow the same structure for maintainability
4. **Design System**: Use `Page.BodyHeader` for filters, `Empty` for empty states, `Search` for search inputs
5. **State Management**: Keep state local to feature pages or use feature-specific stores
## Migration Checklist
When refactoring an existing route:
- [ ] Create feature directory structure (`lib/features/[feature]/`)
- [ ] Move business logic from route file to feature page
- [ ] Extract components to `components/` directory
- [ ] Create `components/index.ts` with exports
- [ ] Create `pages/index.ts` with page export
- [ ] Update route file to be minimal wrapper
- [ ] Add `Page.BodyHeader` for filters/search if needed
- [ ] Replace custom empty states with `Empty` component
- [ ] Replace custom search inputs with `Search` component
- [ ] Test all functionality works correctly
+1 -2
View File
@@ -38,7 +38,7 @@
"ai": "^2.1.31",
"axios": "^1.8.4",
"better-auth": "^1.3.34",
"bits-ui": "^2.14.1",
"bits-ui": "^2.14.4",
"body-parser": "^1.20.2",
"bufferutil": "^4.0.9",
"canvas-confetti": "^1.6.0",
@@ -109,7 +109,6 @@
"svelte": "^5.0.0",
"svelte-dnd-action": "^0.9.24",
"svelte-jester": "^3.0.0",
"svelte-loading-spinners": "^0.3.4",
"svelte-preprocess": "^6.0.0",
"tailwindcss": "^4.1.14",
"ts-jest": "^29.1.1",
+191 -11
View File
@@ -1,4 +1,6 @@
/*Write your global styles here, in PostCSS syntax */
@import url('https://fonts.googleapis.com/css2?family=Geist:[email protected]&display=swap');
@import 'tailwindcss';
@import '@cio/ui/output.css';
@@ -44,17 +46,6 @@ html .dark {
border: 2px solid var(--scrollbarBG);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: revert;
font-weight: revert;
margin: 0.8em 0;
}
.prose h1,
.prose h2,
.prose h3,
@@ -809,6 +800,7 @@ svg.lucide-icon:not(.custom) {
--animate-roll: quizRoll 0.8s ease-in-out;
--animate-gather: audienceGather 0.7s ease-in-out;
--animate-setup-configure: setupConfigure 1s ease-in-out;
--animate-border: border 4s linear infinite;
}
@keyframes shakeAnnouncement {
@@ -1152,3 +1144,191 @@ svg.lucide-icon:not(.custom) {
transform: scale(1) rotate(0deg);
}
}
/* Animated Gradient Border */
@property --border-angle {
inherits: false;
initial-value: 0deg;
syntax: '<angle>';
}
@keyframes border {
to {
--border-angle: 360deg;
}
}
/* Animated Gradient Border Class */
.animate-gradient-border {
border: 1px solid transparent;
background:
linear-gradient(45deg, white, white) padding-box,
conic-gradient(
from var(--border-angle),
rgb(148 163 184 / 0.3) 75%,
rgb(37 99 235) 82%,
rgb(96 165 250) 88%,
rgb(37 99 235) 92%,
rgb(148 163 184 / 0.3) 100%
)
border-box;
transition: --border-angle 3s ease-in-out;
}
.animate-gradient-border:hover {
animation: border 4s ease-in-out infinite;
}
.dark .animate-gradient-border {
background:
linear-gradient(45deg, rgb(0 0 0), rgb(0 0 0)) padding-box,
conic-gradient(
from var(--border-angle),
rgb(71 85 105 / 0.48) 75%,
rgb(37 99 235) 82%,
rgb(96 165 250) 88%,
rgb(37 99 235) 92%,
rgb(71 85 105 / 0.48) 100%
)
border-box;
transition: --border-angle 3s ease-in-out;
}
.dark .animate-gradient-border:hover {
animation: border 4s ease-in-out infinite;
}
/**
Rocket launch animation.
**/
.rocket-launch {
transform-origin: center bottom;
transform-box: fill-box;
}
/* Full sequence runs once per hover */
.animate-icon:hover .rocket-launch {
animation: rocket-sequence 5s ease-in-out infinite;
}
/* Optional: flame flicker during pre-launch + launch */
.animate-icon:hover .rocket-launch path:first-of-type {
animation: flame-sequence 5s ease-in-out infinite;
}
/*
Rocket:
- 0–60% (~3s): jitter/pre-launch, small moves along nose direction
- 60–70%: launch along ~60° diagonal (up-right), fade out
- 70–80%: invisible pause
- 80–100%: re-enter from opposite side (down-left)
*/
@keyframes rocket-sequence {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
10% {
transform: translate(1px, -2px) scale(1.02);
opacity: 1;
}
20% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
30% {
transform: translate(2px, -3px) scale(1.03);
opacity: 1;
}
40% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
50% {
transform: translate(1px, -2px) scale(1.02);
opacity: 1;
}
60% {
transform: translate(0, 0) scale(1.05);
opacity: 1;
}
/* Launch along a ~60° diagonal: up (-Y) and to the right (+X) */
70% {
transform: translate(100%, -173%) scale(1.1);
opacity: 0;
}
/* Off-screen / invisible pause (~1s, 70–80%) */
80% {
/* Reposition off-screen in the opposite direction: down-left */
transform: translate(-100%, 173%) scale(1.1);
opacity: 0;
}
/* Fly back into view from below-left to original position */
100% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
/*
Flame sequence:
- Moves opposite rocket travel (down-left: -X, +Y) to match exhaust
- No vertical-only scaling; just uniform scale + diagonal translation
- Visible 0–70%, then off
*/
@keyframes flame-sequence {
0% {
stroke: var(--main-primary-color);
opacity: 1;
transform: translate(-0.5px, 0.9px) scale(1.02);
}
10% {
opacity: 1;
transform: translate(0, 0) scale(1);
}
20% {
opacity: 1;
transform: translate(0, 0) scale(1.02);
}
30% {
opacity: 0.8;
transform: translate(-0.5px, 0.9px) scale(1);
}
40% {
opacity: 0.8;
transform: translate(0, 0) scale(1.02);
}
50% {
opacity: 1;
transform: translate(-0.5px, 0.9px) scale(1);
}
60% {
opacity: 0.8;
transform: translate(0, 0) scale(1.02);
}
70% {
opacity: 0;
transform: translate(-0.5px, 0.9px) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-1px, 1.7px) scale(1.12);
}
}
@@ -1,124 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import { authClient } from '$lib/utils/services/auth/client';
interface Props {
handleSubmit?: any;
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
formRef?: any;
hideGoogleAuth?: boolean;
redirectPathname?: string;
children?: import('svelte').Snippet;
}
let {
handleSubmit = () => {},
isLogin = true,
showOnlyContent = false,
isLoading = false,
showLogo = false,
formRef = $bindable(),
hideGoogleAuth = false,
redirectPathname = '',
children
}: Props = $props();
async function signInWithGoogle() {
if (isLoading) {
return;
}
const params = new URLSearchParams(window.location.search);
console.log({ params });
// const redirectTo = `https://app.classroomio.com?forwardTo=${
// window.location.origin + params.get('redirect')
// }`;
const pathname = redirectPathname || params.get('redirect') || '';
const redirectTo = `${window.location.origin + pathname}`;
console.log({ redirectTo });
try {
console.log('signInWithGoogle');
const data = await authClient.signIn.social({
provider: 'google',
callbackURL: redirectTo
});
console.log('data', data);
} catch (error) {
console.log('catch error', error);
}
}
</script>
<div class="app-background flex min-h-screen w-full items-center justify-center">
<div class="border-gray container border bg-white dark:bg-black">
<div class="flex flex-col items-center p-2 lg:px-8 lg:py-3">
{#if !showOnlyContent || showLogo}
<div class="flex w-full flex-col items-center justify-center pt-2">
<Avatar
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="max-h-10"
className="mr-2"
/>
<a href="/">
<h4 class="mt-0 text-xl dark:text-white">
{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
</h4>
</a>
</div>
{/if}
<form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
{@render children?.()}
</form>
{#if !showOnlyContent && !hideGoogleAuth}
<div class="mb-3 w-10/12">
<p class="mb-5 text-sm dark:text-white">{$t('login.signup_with')}:</p>
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={signInWithGoogle}
isDisabled={isLoading}
className="py-3 sm:w-full w-full"
>
<GoogleIconColored />
<span class="ml-2">
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</PrimaryButton>
</div>
{/if}
</div>
{#if !showOnlyContent}
<div class="border-grey w-full border-t p-6 text-center">
{#if isLogin}
{$t('login.not_registered_yet')}
<a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
{:else}
{$t('login.already_have_account')}
<a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
{/if}
</div>
{/if}
</div>
</div>
<style>
.container {
width: 450px;
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
interface Props {
@@ -23,7 +23,7 @@
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
{#if isClosing}
<Circle size="20" color="#1d4ee2" unit="px" duration="1s" />
<Spinner class="text-blue-700! size-5" />
{:else}
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
{/if}
@@ -1,7 +1,6 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import { t } from '$lib/utils/functions/translations';
@@ -18,7 +17,7 @@
</script>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
<PrimaryButton variant={VARIANTS.CONTAINED_DARK}>
<Button variant="outline">
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
@@ -27,5 +26,5 @@
{/if}
{/if}
<ArrowUpRightIcon />
</PrimaryButton>
</Button>
</a>
@@ -16,7 +16,7 @@
import Plain from './templates/Plain.svelte';
import Professional from './templates/Professional.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
@@ -10,7 +10,7 @@
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import Progress from '$lib/components/Progress/index.svelte';
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -29,7 +29,7 @@
import { IconButton } from '$lib/components/IconButton';
import UpdateDescription from './UpdateDescription.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
interface Props {
exerciseId?: string;
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import { lesson } from '../store/lessons';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
@@ -15,11 +15,11 @@
import { PageBody } from '$lib/components/Page';
import Exercise from '../Exercise/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import * as Empty from '@cio/ui/base/empty';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
interface Props {
@@ -160,7 +160,7 @@
{#if isFetching}
<Backdrop>
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
@@ -204,22 +204,24 @@
<p class="mt-4 text-sm dark:text-white">{formatDate(exercise.created_at)}</p>
</a>
{:else}
<Box className="mt-3 text-center">
<div class="flex justify-between flex-col items-center w-[80%] md:w-96">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">
<Empty.Root class="mt-3">
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="size-6" />
</Empty.Media>
<Empty.Title>
{$t('course.navItem.lessons.exercises.no_exercises')}
</h2>
<p class="text-sm text-center text-slate-500">
</Empty.Title>
<Empty.Description>
{#if $globalStore.isStudent}
{$t('course.navItem.lessons.exercises.no_assigned_exercises')}
<strong> {$t('course.navItem.lessons.exercises.chill')} :)</strong>
{:else}
{$t('course.navItem.lessons.exercises.add_exercise')}
{/if}
</p>
</div>
</Box>
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</div>
</PageBody>
@@ -6,10 +6,10 @@
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import Box from '$lib/components/Box/index.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import * as Empty from '@cio/ui/base/empty';
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { globalStore } from '$lib/utils/store/app';
@@ -188,14 +188,16 @@
</div>
</div>
{:else}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">{$t('course.navItem.lessons.body_header')}</h2>
<p class="text-sm text-center text-slate-500">
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="size-6" />
</Empty.Media>
<Empty.Title>{$t('course.navItem.lessons.body_header')}</Empty.Title>
<Empty.Description>
{$t('course.navItem.lessons.body_content')}
</p>
</div>
</Box>
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</section>
@@ -27,7 +27,7 @@
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
type CrudParam = {
sectionId?: string;
@@ -7,7 +7,7 @@
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
@@ -1,7 +1,7 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { preventDefault } from '$lib/utils/functions/svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import { isFreePlan } from '$lib/utils/store/org';
@@ -17,7 +17,7 @@
import Chip from '$lib/components/Chip/index.svelte';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
interface Props {
feed: Feed;
@@ -13,7 +13,7 @@
import { qrInviteNodeStore } from './store';
import { ROLE } from '@cio/utils/constants';
import { getOrgTeam } from '$lib/utils/services/org';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
@@ -37,10 +37,8 @@
let tutors: Tutor[] = $state([]);
let selectedIds: string[] = $state([]);
let isLoadingTutors = $state(false);
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
let copied = $state(false);
let qrImage = $state('');
let isLoadingQRDownload = $state(false);
@@ -108,18 +106,13 @@
if (!orgId) return;
untrack(async () => {
isLoadingTutors = true;
const { team, error } = await getOrgTeam(orgId);
if (error) {
console.error('Error fetching teams', error);
isLoadingTutors = false;
await orgApi.getOrgTeam(orgId);
if (orgApi.error) {
console.error('Error fetching teams', orgApi.error);
return;
}
tutors = getTutors(team);
isLoadingTutors = false;
tutors = getTutors(orgApi.teamMembers);
});
}
@@ -132,10 +125,6 @@
const link = getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain);
copy(link);
copied = true;
setTimeout(() => {
copied = false;
}, 1000);
}
function closeModal() {
@@ -197,7 +186,7 @@
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={isLoadingTutors}>
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
@@ -231,7 +220,7 @@
{/each}
</Select.Content>
</Select.Root>
{#if isLoadingTutors}
{#if orgApi.isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Loading...</span>
@@ -287,7 +276,7 @@
</div>
</div>
<div class="absolute left-[-1000px] w-[40rem]">
<div class="w-160 absolute left-[-1000px]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { CurrentOrg } from '$lib/utils/types/org.ts';
import type { Course } from '$lib/utils/types/index.ts';
import type { AccountOrg } from '$lib/features/app/types';
import type { Course } from '$lib/utils/types';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
@@ -12,7 +12,7 @@
interface Props {
qrImage: string;
currentOrg: CurrentOrg;
currentOrg: AccountOrg;
course: Course;
}
@@ -22,7 +22,7 @@
<div
bind:this={node}
id="qr-container"
class="flex h-[60rem] w-[40rem] flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
class="h-160 w-160 flex flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
>
<div class="rounded-3xl bg-white p-6 pb-3 text-center">
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
@@ -13,7 +13,7 @@
import TextField from '$lib/components/Form/TextField.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import Navigation from '../Course/components/Navigation/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { course, group, courseStore } from '../Course/store';
@@ -65,7 +65,7 @@
{#if isFetching}
<Backdrop>
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
@@ -7,7 +7,7 @@
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import UploadImage from '$lib/components/UploadImage/index.svelte';
@@ -18,18 +18,18 @@
let { course = $bindable(), setter }: Props = $props();
let avatar: string | undefined = $state();
let avatar: File | undefined = $state();
let templateUrl = $derived(get(course, 'metadata.certificate.templateUrl', '/images/certificate-template.svg'));
let isUploading = $state(false);
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.CERTIFICATE}`) ?? true);
async function onTemplateChange(_avatar: string | undefined) {
async function onTemplateChange(_avatar: File | undefined) {
untrack(async () => {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadAvatar(course.id, _avatar);
const logo = await uploadImage(_avatar);
if (logo) {
templateUrl = logo;
@@ -3,7 +3,7 @@
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import type { Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
@@ -19,13 +19,14 @@
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
let description = $state(get(course, 'metadata.instructor.description'));
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
let avatar: string | undefined = $state();
let avatar: File | undefined = $state();
let isUploading = $state(false);
async function onAvatarChange(_avatar: string | undefined) {
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadAvatar(course.id, _avatar);
const logo = await uploadImage(_avatar);
if (!logo) return;
@@ -5,26 +5,26 @@
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
let { courseId, reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
let { reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
let avatar = $state<string | undefined>();
let avatar = $state<File | undefined>();
// function to delete review
function deleteReviewData() {
reviews = reviews.filter((r) => r.id !== review.id);
}
async function onAvatarChange(_avatar) {
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar) return;
review.avatar_url = await uploadAvatar(courseId, _avatar);
review.avatar_url = await uploadImage(_avatar);
}
$effect(() => {
@@ -23,7 +23,7 @@
import ImageRenderer from '../Org/ImageRenderer.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import PricingSection from './components/PricingSection.svelte';
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
import { PoweredBy } from '$lib/features/ui';
import SectionsDisplay from './components/SectionsDisplay.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
@@ -2,9 +2,10 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
const gotoCourse = (id: string | undefined) => {
if (!id) return;
@@ -54,11 +55,7 @@
{/each}
</div>
{:else}
<div class="flex flex-col items-center">
<CoursesEmptyIcon size={16} />
<h3 class="text-center">{$t('dashboard.no_courses')}</h3>
<p class="w-[75%] text-center">{$t('dashboard.start_course')}</p>
</div>
<Empty title={$t('dashboard.no_courses')} description={$t('dashboard.start_course')} icon={BookOpenIcon} />
{/if}
</div>
</section>
@@ -64,7 +64,7 @@
message = $t('username already exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
@@ -42,7 +42,7 @@
{#if open}
<div
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
class="dialog z-100 fixed inset-0 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
onclick={onClose}
role="presentation"
>
@@ -1,7 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -11,45 +9,24 @@
onMobileClick?: (() => void) | undefined;
}
let {
disableSignup = false,
redirect = '',
isMobile = false,
onMobileClick = undefined
}: Props = $props();
function handleLogin() {
if (onMobileClick) onMobileClick();
goto('/login' + redirect);
}
function handleSignup() {
if (onMobileClick) onMobileClick();
goto('/signup' + redirect);
}
let { disableSignup = false, redirect = '', isMobile = false, onMobileClick = undefined }: Props = $props();
</script>
<div class="flex {isMobile ? 'flex-col items-center justify-center space-y-3' : ''}">
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.login')}
variant={VARIANTS.TEXT}
onClick={handleLogin}
className={isMobile ? 'w-full justify-center' : ''}
/>
<Button variant="link" size="sm" href={'/login' + redirect} onclick={onMobileClick}>
{$t('navigation.login')}
</Button>
</div>
</li>
{#if !disableSignup}
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.signup')}
variant={VARIANTS.CONTAINED}
onClick={handleSignup}
className={isMobile ? 'w-full justify-center' : ''}
/>
<Button variant="link" size="sm" href={'/signup' + redirect} onclick={onMobileClick}>
{$t('navigation.signup')}
</Button>
</div>
</li>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { TCustomLinks } from './types';
import { Button } from '@cio/ui/base/button';
interface Props {
customLinks?: TCustomLinks | undefined;
@@ -18,17 +19,16 @@
<div class={isMobile ? 'space-y-4' : 'mr-4 hidden items-center space-x-6 lg:flex'}>
{#each customLinks.links as link}
<li>
<a
<Button
href={link.url}
target={link.openInNewTab ? '_blank' : '_self'}
rel={link.openInNewTab ? 'noopener noreferrer' : ''}
class={isMobile
? 'hover:text-primary-600 block rounded-md px-3 py-2 font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-50'
: 'hover:text-primary-600 rounded-md px-4 py-2 font-medium text-gray-700 no-underline transition-all duration-200 ease-in-out hover:bg-gray-100'}
variant="link"
size="sm"
onclick={handleLinkClick}
>
{link.label}
</a>
</Button>
</li>
{/each}
</div>
@@ -4,7 +4,6 @@
import { isCoursePage } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
import type { TCustomLinks } from './types';
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
import Logo from './Logo.svelte';
import CustomLinks from './CustomLinks.svelte';
@@ -77,8 +76,6 @@
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
<AuthButtons {disableSignup} {redirect} />
{/if}
<ModeSwitcher />
</ul>
{#if showLinks}
@@ -2,104 +2,29 @@
import { preventDefault } from '$lib/utils/functions/svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
import { Input } from '@cio/ui/base/input';
import { DomainInput } from '@cio/ui/custom/domain-input';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { generateSitename } from '$lib/utils/functions/org';
import { getOrganizations } from '$lib/utils/services/org';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { newOrgModal } from '../store';
import { snackbar } from '$lib/components/Snackbar/store';
import { createOrgValidation } from '$lib/utils/functions/validator';
import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
type Error = {
orgName: string;
siteName: string;
};
let loading = $state(false);
let orgName = $state('');
let siteName = $derived(generateSitename(orgName));
let errors: Error = $state({
orgName: '',
siteName: ''
});
function resetForm() {
orgName = '';
siteName = '';
loading = false;
errors = {
orgName: '',
siteName: ''
};
orgApi.errors = {};
}
async function createNewOrg() {
errors = createOrgValidation({
orgName,
siteName
}) as Error;
await orgApi.create({ name: orgName, siteName });
if (Object.values(errors).length) {
loading = false;
return;
}
// Validate if domain is among our seculeded subdomains
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
const { data: org, error } = await supabase
.from('organization')
.insert({
name: orgName,
siteName: siteName
})
.select();
console.log('Create organisation', org);
if (error) {
console.log('Error: create organisation', error);
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
if (Array.isArray(org) && org.length) {
const orgData = org[0];
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: orgData.id,
profile_id: $profile.id,
role_id: 1
})
.select();
console.log('Create organisation member', data);
if (error) {
console.log('Error: create organisation member', error);
errors.siteName = $t('add_org.error_organization');
// Delete organization so it can be recreated.
await supabase.from('organization').delete().match({ siteName });
loading = false;
return;
}
snackbar.success();
await getOrganizations($profile.id);
goto(`/org/${siteName}`);
if (orgApi.success) {
$newOrgModal.open = false;
resetForm();
}
}
@@ -112,32 +37,28 @@
modalHeading={$t('add_org.create_org')}
>
<form onsubmit={preventDefault(createNewOrg)} class="px-2">
<TextField
label={$t('add_org.name')}
bind:value={orgName}
autoFocus={true}
placeholder="e.g Pepsi Co"
className="mb-4"
isRequired={true}
errorMessage={errors.orgName}
autoComplete={false}
/>
<!-- Org Site Name -->
<TextField
label={$t('add_org.org_sitename')}
helperMessage={`https://${siteName || ''}.classroomio.com`}
bind:value={siteName}
name="sitename"
type="text"
placeholder="e.g edforall"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.siteName}
isRequired={true}
/>
<Field.Group>
<Field.Field>
<Field.Label>{$t('add_org.name')}</Field.Label>
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
{#if orgApi.errors.name || orgApi.errors.orgName}
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
{/if}
</Field.Field>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton className="px-6 py-3" label={$t('add_org.create')} type="submit" isLoading={loading} />
</div>
<Field.Field>
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if orgApi.errors.siteName || orgApi.errors.general}
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
{$t('add_org.create')}
</Button>
</Field.Field>
</Field.Group>
</form>
</Modal>
@@ -1,128 +0,0 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import Search from '@lucide/svelte/icons/search';
import { Skeleton } from '@cio/ui/base/skeleton';
import * as Pagination from '@cio/ui/base/pagination';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, orgAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
interface Props {
isLoading?: boolean;
}
let { isLoading = false }: Props = $props();
const headers = [
{ key: 'name', value: $t('audience.name') },
{ key: 'email', value: $t('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') }
];
let pageSize = $state(5);
let currentPage = $state(1);
let searchValue = $state('');
const filteredRows = $derived(
searchValue
? $orgAudience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: $orgAudience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
<div class="w-full space-y-4">
<!-- Search Toolbar -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input type="text" placeholder="Search..." bind:value={searchValue} />
</div>
</div>
<!-- Table -->
{#if isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline"
>
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.date_joined}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between">
<Pagination.Root
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div>
@@ -1,39 +0,0 @@
<script>
import Box from '$lib/components/Box/index.svelte';
import AudienceList from './AudienceList.svelte';
import AudienceEmptyIcon from '$lib/components/Icons/AudienceEmptyIcon.svelte';
import { getOrgAudience } from '$lib/utils/services/org';
import { orgAudience, currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { untrack } from 'svelte';
let isLoading = $state(false);
function fetchInitData(orgId) {
if (!orgId) return;
untrack(async () => {
isLoading = true;
await getOrgAudience(orgId);
isLoading = false;
});
}
$effect(() => {
fetchInitData($currentOrg.id);
});
</script>
<div class="m-auto my-4 flex flex-wrap items-center justify-center lg:justify-start">
{#if $orgAudience.length || isLoading}
<AudienceList {isLoading} />
{:else}
<Box>
<AudienceEmptyIcon size={16} />
<h3 class="my-5 text-center text-2xl dark:text-white">{$t('audience.no_audience')}!</h3>
<p class="w-1/3 text-center dark:text-white">
{$t('audience.manage')}.
</p>
</Box>
{/if}
</div>
@@ -1,26 +0,0 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div
class="relative mx-4 my-2 flex h-[70px] w-full rounded-md border border-gray-100 px-3 pt-3 transition ease-in-out dark:border-neutral-600 dark:bg-neutral-800"
>
<div class="mr-2 flex w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-[40%] w-full rounded-sm" />
<Skeleton class="h-[40%] w-full rounded-sm" />
<Skeleton class="h-[40%] w-full rounded-sm" />
</div>
<div class="mt-1.5 flex w-9/12 flex-col justify-evenly rounded-md">
<!-- first text -->
<div class="h-full w-3/6">
<Skeleton class="h-[50%] w-full rounded-sm" />
</div>
<!-- second text -->
<Skeleton class="h-4 w-1/4 rounded-sm" />
</div>
<div class="ml-auto mt-1.5 w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-[41%] w-full rounded-sm" />
</div>
</div>
@@ -1,6 +1,6 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
import { CourseCardLoader } from '$lib/features/course/components';
</script>
<section class="h-screen w-screen p-5">
@@ -19,8 +19,8 @@
</div>
<!-- cards -->
<div class="my-4 flex flex-wrap items-center justify-center gap-5 overflow-hidden rounded-md">
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</div>
</section>
@@ -12,32 +12,29 @@
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
import { preventDefault } from '$lib/utils/functions/svelte';
import Box from '$lib/components/Box/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Navigation from '$lib/components/Navigation/index.svelte';
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Card from '$lib/components/Courses/components/Card/index.svelte';
import { PoweredBy } from '$lib/features/ui';
import { CourseCard, CourseCardLoader } from '$lib/features/course/components';
import { landingPageSettings } from '$lib/components/Org/Settings/store';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
import PageLoader from './PageLoader.svelte';
import { Row, Grid, Column } from '../Settings/Layout';
import * as Accordion from '@cio/ui/base/accordion';
import * as Button from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import type { CurrentOrg } from '$lib/utils/types/org';
import { getSupabase } from '$lib/utils/functions/supabase';
import { getCourseBySiteName } from '$lib/utils/services/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { AccountOrg } from '$lib/features/app/types';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { validateEmail } from '$lib/utils/functions/validateEmail';
import { courseMetaDeta, courses } from '$lib/components/Courses/store';
import { courseMetaDeta, courses } from '$lib/features/course/utils/store';
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
interface Props {
orgSiteName?: string;
org: CurrentOrg | null;
org: AccountOrg;
}
let { orgSiteName = '', org }: Props = $props();
@@ -57,20 +54,11 @@
});
let contactError: Record<string, string> = $state({});
const supabase = getSupabase();
async function handleSubmit() {
if (!email || !validateEmail(email) || !org) return;
isAdding = true;
const { error } = await supabase.from('organization_emaillist').insert({
email,
organization_id: org.id
});
if (error) {
console.error('Error', error);
}
// TODO: Implement email list submission
success = true;
setTimeout(() => {
@@ -89,18 +77,7 @@
}
// Save to db
const { error } = await supabase.from('organization_contacts').insert({
name: contact.name,
email: contact.email,
phone: contact.phone,
message: contact.message,
organization_id: org?.id
});
if (error) {
console.error('Something went wrong', error, '\n\nContact', contact);
isContactSubmiting = false;
}
// TODO: Implement contact submission
isContactSubmiting = false;
successContactSaved = true;
@@ -123,11 +100,17 @@
untrack(async () => {
try {
$courseMetaDeta.isLoading = true;
const coursesResult = await getCourseBySiteName(siteName);
courses.set(coursesResult);
await orgApi.getCourseBySiteName(siteName);
if (orgApi.error) {
console.error('Error fetching courses', orgApi.error);
courses.set([]);
} else {
courses.set(orgApi.orgSiteCourses);
}
$courseMetaDeta.isLoading = false;
} catch (error) {
console.log('error', error);
$courseMetaDeta.isLoading = false;
}
});
}
@@ -157,18 +140,18 @@
}
function getBgImage(settings) {
const defaultImg = '/images/org-landingpage-banner.jpeg';
const { show, image } = settings.header.background || {
show: true,
image: '/images/org-landingpage-banner.jpeg'
image: defaultImg
};
if (!show) {
return undefined;
}
if (image) {
return `url('${image}')`;
}
return `url('${image || defaultImg}')`;
}
$effect(() => {
@@ -195,7 +178,7 @@
{:else}
<main>
<Navigation
logo={org.avatar_url}
logo={org.avatarUrl}
orgName={org.name}
disableSignup={true}
isOrgSite={true}
@@ -205,12 +188,12 @@
{#if $landingPageSettings.header.show}
<header
id="header"
class={`relative mb-10 h-[100vh] w-full md:h-[90vh] ${
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
}`}
style="background-image: {getBgImage($landingPageSettings)}"
>
<div class="absolute top-0 z-10 h-[100vh] w-full bg-white opacity-80 md:h-[90vh]"></div>
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
{#if $landingPageSettings.header.banner.show}
<div class="flex items-center justify-center py-2 md:h-full">
<div
@@ -230,13 +213,14 @@
{$landingPageSettings.header.subtitle}
</p>
<PrimaryButton
label={$landingPageSettings.header.action.label}
className="mt-2 md:mt-5 px-10 w-fit"
onClick={() => {
<Button.Root
class="mt-2 w-fit px-10 md:mt-5"
onclick={() => {
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
}}
/>
>
{$landingPageSettings.header.action.label}
</Button.Root>
</div>
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
@@ -275,13 +259,14 @@
{$landingPageSettings.header.subtitle}
</p>
<PrimaryButton
label={$landingPageSettings.header.action.label}
className="mt-5 px-10 w-fit"
onClick={() => {
<Button.Root
class="mt-5 w-fit px-10"
onclick={() => {
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
}}
/>
>
{$landingPageSettings.header.action.label}
</Button.Root>
</div>
</div>
{/if}
@@ -322,14 +307,14 @@
</div>
{#if $courseMetaDeta.isLoading}
<div class="cards-container mx-2 my-4">
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</div>
{:else if $courses.length > 0}
<div class="cards-container mx-2 my-4">
{#each $courses.slice(0, viewAll ? $courses.length : 3) as courseData}
<Card
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
@@ -350,25 +335,19 @@
{/each}
</div>
{:else}
<Box>
<CoursesEmptyIcon />
<h3 class="my-5 text-2xl dark:text-white">
{$t('course.navItem.landing_page.no_course_published')}
</h3>
<p class="w-1/3 text-center dark:text-white">
{$t('course.navItem.landing_page.coming_your_way')}
</p>
</Box>
<Empty
icon={CoursesEmptyIcon}
title={$t('course.navItem.landing_page.no_course_published')}
description={$t('course.navItem.landing_page.coming_your_way')}
variant="page"
/>
{/if}
{#if $courses.length > 3}
<div class="mt-3 flex w-full justify-center">
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={() => (viewAll = !viewAll)}
label={viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
className="px-10 py-5 w-fit"
/>
<Button.Root variant="outline" onclick={() => (viewAll = !viewAll)} class="w-fit px-10 py-5">
{viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
</Button.Root>
</div>
{/if}
</section>
@@ -386,13 +365,11 @@
<Accordion.Root type="single" class="w-full">
{#each $landingPageSettings.faq.questions as faq, index}
<Accordion.Item value="item-{index}">
<Accordion.Trigger class="text-left text-lg font-medium">
<Accordion.Trigger>
{faq.title}
</Accordion.Trigger>
<Accordion.Content>
<p class="text-lg">
{faq.content}
</p>
{faq.content}
</Accordion.Content>
</Accordion.Item>
{/each}
@@ -412,30 +389,30 @@
</h1>
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
</div>
<Grid class="max-w-[700px] pb-10">
<div class="mx-auto max-w-[700px] pb-10">
<!-- Contact Details -->
<Row>
<Column
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<MapPinIcon size={16} class="filled" />
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
{$landingPageSettings.contact.address}
</p>
</Column>
<Column
</div>
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<PhoneIcon size={16} />
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
</Column>
<Column
</div>
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<MailIcon size={16} />
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
</Column>
</Row>
</div>
</div>
<!-- Contact Form -->
<div class="mt-8 rounded-lg bg-white p-7">
@@ -447,48 +424,69 @@
<form onsubmit={preventDefault(handleContactSubmit)}>
<div class="flex w-full flex-col justify-between md:flex-row">
<div class="mr-5 w-full md:w-2/4">
<TextField
label={$t('course.navItem.landing_page.name')}
bind:value={contact.name}
errorMessage={contactError.name}
className="mb-5"
labelClassName="font-bold"
placeholder="Elon Musk"
/>
<TextField
label={$t('course.navItem.landing_page.email')}
bind:value={contact.email}
errorMessage={contactError.email}
className="text-xs font-normal mb-5"
placeholder="[email protected]"
/>
<TextField
label={$t('course.navItem.landing_page.phone')}
bind:value={contact.phone}
errorMessage={contactError.phone}
className="text-xs font-normal mb-5"
placeholder="+1194802480"
/>
<Field.Field class="mb-5">
<Field.Label class="font-bold">
{$t('course.navItem.landing_page.name')}
</Field.Label>
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
{#if contactError.name}
<Field.Error>{contactError.name}</Field.Error>
{/if}
</Field.Field>
<Field.Field class="mb-5">
<Field.Label class="text-xs font-normal">
{$t('course.navItem.landing_page.email')}
</Field.Label>
<Input
bind:value={contact.email}
type="email"
placeholder="[email protected]"
aria-invalid={!!contactError.email}
/>
{#if contactError.email}
<Field.Error>{contactError.email}</Field.Error>
{/if}
</Field.Field>
<Field.Field class="mb-5">
<Field.Label class="text-xs font-normal">
{$t('course.navItem.landing_page.phone')}
</Field.Label>
<Input
bind:value={contact.phone}
placeholder="+1194802480"
aria-invalid={!!contactError.phone}
/>
{#if contactError.phone}
<Field.Error>{contactError.phone}</Field.Error>
{/if}
</Field.Field>
</div>
<div class="w-full md:w-2/4">
<TextArea
label={$t('course.navItem.landing_page.message')}
bind:value={contact.message}
errorMessage={contactError.message}
rows={9}
placeholder={$t('course.navItem.landing_page.your_message')}
/>
<Field.Field>
<Field.Label>
{$t('course.navItem.landing_page.message')}
</Field.Label>
<Textarea
bind:value={contact.message}
rows={9}
placeholder={$t('course.navItem.landing_page.your_message')}
aria-invalid={!!contactError.message}
/>
{#if contactError.message}
<Field.Error>{contactError.message}</Field.Error>
{/if}
</Field.Field>
</div>
</div>
<PrimaryButton className="w-full mx-auto mt-5 md:mt-0" type="submit" isLoading={isContactSubmiting}>
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
<RocketIcon size={16} />
</PrimaryButton>
</Button.Root>
</form>
{/if}
</div>
</Grid>
</div>
</div>
</section>
{/if}
@@ -496,38 +494,35 @@
<!-- Waitlist Section -->
{#if $landingPageSettings.mailinglist.show}
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
<div class="bg-primary-700 flex flex-col rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
<div class="w-full md:mr-4 md:w-[65%]">
<h1 class="mb-5 mt-0 text-4xl text-white">
{$landingPageSettings.mailinglist.title}
</h1>
<p class="text-lg text-white">
{$landingPageSettings.mailinglist.subtitle}
</p>
</div>
<form onsubmit={preventDefault(handleSubmit)} class="my-4 w-full md:w-fit">
<div class="flex flex-col items-center sm:flex-row">
{#if success}
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
{:else}
<TextField
bind:value={email}
type="email"
placeholder={$t('course.navItem.landing_page.enter')}
className="sm:mr-3 my-0 w-full md:w-fit"
isRequired={true}
isDisabled={isAdding}
inputClassName="py-2"
/>
<PrimaryButton
className="my-1 w-full mt-2"
variant={VARIANTS.CONTAINED_LIGHT}
type="submit"
isLoading={isAdding}>{$landingPageSettings.mailinglist.buttonLabel}</PrimaryButton
>
{/if}
<div class="bg-primary-700 gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="w-3/5">
<h1 class="mb-5 mt-0 text-4xl text-white">
{$landingPageSettings.mailinglist.title}
</h1>
<p class="text-md text-white">
{$landingPageSettings.mailinglist.subtitle}
</p>
</div>
</form>
<form onsubmit={preventDefault(handleSubmit)} class="">
<div class="flex max-w-60 flex-col items-center">
{#if success}
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
{:else}
<Input
bind:value={email}
type="email"
placeholder={$t('course.navItem.landing_page.enter')}
required={true}
disabled={isAdding}
/>
<Button.Root class="my-1 mt-2 w-full" type="submit" loading={isAdding}>
{$landingPageSettings.mailinglist.buttonLabel}
</Button.Root>
{/if}
</div>
</form>
</div>
</div>
</section>
{/if}
@@ -543,7 +538,7 @@
<div class="logo">
<a href="/" title={`Go to ${org.name} Home`} id="logo" data-hveid="8" class="flex items-center">
<img
src={org.avatar_url || '/logo-192.png'}
src={org.avatarUrl || '/logo-192.png'}
alt={`${org.name} logo`}
class="mx-auto inline-block max-h-10 w-10 rounded"
data-atf="1"
@@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { SyncLoader } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import PlayContainer from './Container.svelte';
import PlayHeader from './Header/index.svelte';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
@@ -43,7 +43,7 @@
<div class="">
<p>2. Enter Pin</p>
{#if isGettingPin}
<SyncLoader size="50" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-10! text-blue-700!" />
{:else}
<h3>{$quizStore.pin}</h3>
{/if}
@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { SyncLoader } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
import ConnectToPlay from './ConnectToPlay.svelte';
@@ -39,7 +39,7 @@
<Podium />
{:else}
<div class="m-auto w-full">
<SyncLoader size="20" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-8! text-blue-700!" />
</div>
{/if}
</div>
@@ -37,7 +37,7 @@
if (error) {
const message = error?.message || 'snackbar.lms.error.try_again';
console.error('Error updating customizations', message);
snackbar.error('snackbar.lms.error.update');
snackbar.error('snackbar.update_failed');
} else {
snackbar.success('snackbar.success_update');
}
@@ -25,7 +25,7 @@
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
@@ -139,8 +139,8 @@
.match({ id: $currentOrg.id });
if (error) {
const message = error?.message || 'snackbar.lms.error.try_again';
snackbar.error(`snackbar.lms.error.update ${message}`);
const message = error?.message || $t('snackbar.lms.error.try_again');
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
} else {
$currentOrg.landingpage = $landingPageSettings as any;
snackbar.success('snackbar.success_update');
@@ -139,7 +139,7 @@
message = $t('snackbar.lms.error.username_exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')} ${message}`);
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
loading = false;
@@ -1,225 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { Circle } from 'svelte-loading-spinners';
import { profile } from '$lib/utils/store/user';
import { isFreePlan } from '$lib/utils/store/org';
import { getOrgTeam } from '$lib/utils/services/org';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { currentOrg, orgTeam } from '$lib/utils/store/org';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { validateEmailInString } from '$lib/utils/functions/validator';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let emailsStr = $state('');
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isFetching = $state(false);
let isLoading = $state(false);
let isRemoving: number | null = $state(null);
async function onSendInvite() {
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
errorMessage = _error;
return;
}
isLoading = true;
let apiError = '';
emails.forEach(async (email: string, index: number) => {
if (apiError) return;
const doesEmailExist = $orgTeam.some((teamMember) => teamMember.email.toLowerCase() === email.toLowerCase());
if (doesEmailExist) {
snackbar.error('snackbar.team_members.user_exists');
isLoading = false;
return;
}
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: $currentOrg.id,
email,
role_id: role,
verified: false
})
.select();
console.log('data', data);
if (error) {
apiError = `${error}`;
console.error('onSendInvite:', error);
snackbar.error(`snackbar.team_members.invite_fail`);
isLoading = false;
return;
}
const [newMember] = data || [];
if (newMember) {
orgTeam.update((team) => [
{
id: newMember.id,
email: newMember?.email,
fullname: newMember?.fullname || '',
verified: newMember?.verified,
role: ROLE_LABEL[newMember?.role_id] || '',
isAdmin: newMember?.role_id === ROLE.ADMIN
},
...team
]);
}
triggerSendEmail(NOTIFICATION_NAME.INVITE_TEACHER, {
email: newMember.email,
org: {
id: $currentOrg.id,
name: $currentOrg.name,
siteName: $currentOrg.siteName
}
});
const isLast = index === emails.length - 1;
if (isLast) {
snackbar.success('snackbar.team_members.invite_sent');
emailsStr = '';
isLoading = false;
}
});
}
async function onRemove(id: number) {
console.log('onRemove called');
isRemoving = id;
const { error } = await supabase.from('organizationmember').delete().match({ id });
if (error) {
console.error('onRemove:', error);
snackbar.error('snackbar.team_members.remove_fail');
} else {
orgTeam.update((team) => [...team.filter((member) => member.id !== id)]);
}
isRemoving = null;
}
const fetchTeam = (id: string) => {
if (!id) return;
untrack(async () => {
isFetching = true;
await getOrgTeam(id);
isFetching = false;
isFetching = false;
});
};
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
return members.some((member) => member.profileId === profileId && member.isAdmin);
};
$effect(() => {
fetchTeam($currentOrg.id);
});
</script>
<Grid class="relative mt-5 w-full rounded border border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c py-7">
<Column sm={4} md={4} lg={4} class="text-lg"
><SectionTitle>{$t('course.navItem.people.teams.add')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
<p class="text-md mb-5 text-gray-500 dark:text-white">
{$t('course.navItem.people.teams.add_team')}
</p>
<div class="">
<TextField
label={$t('course.navItem.people.teams.invite')}
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
className="mb-3"
isDisabled={$isFreePlan}
{errorMessage}
/>
<div class="mb-5">
<Label class="mb-2 block">{$t('course.navItem.people.teams.role')}</Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger class="w-40">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<PrimaryButton
label={$t('course.navItem.people.teams.send_invite')}
onClick={onSendInvite}
{isLoading}
isDisabled={isLoading || $isFreePlan}
/>
</div>
</Column>
</Row>
<Row class="border-bottom-c py-7">
<Column sm={4} md={4} lg={4} class="text-lg"
><SectionTitle>{$t('course.navItem.people.teams.members')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
{#if isFetching}
<Circle />
{:else}
{#each $orgTeam as teamMember}
<div class="mb-5 flex items-center justify-between">
<div class="flex">
<p class="mr-3 text-sm text-gray-500 dark:text-white">
{teamMember.email}
</p>
<TextChip value={$t(teamMember.role)} className="text-xs mr-3" size="sm" />
{#if !teamMember.verified}
<TextChip
value={$t('course.navItem.people.teams.invite_sent')}
className="text-xs bg-yellow-200 text-yellow-700"
size="sm"
/>
{:else if teamMember.profileId === $profile.id}
<ComingSoon label={$t('course.navItem.people.teams.you')} />
{/if}
</div>
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin($orgTeam, $profile.id)}
<PrimaryButton
label={$t('course.navItem.people.teams.remove')}
variant={VARIANTS.TEXT_DANGER}
onClick={() => onRemove(teamMember.id)}
isLoading={isRemoving === teamMember.id}
isDisabled={isRemoving === teamMember.id}
/>
{/if}
</div>
{/each}
{/if}
</Column>
</Row>
</Grid>
@@ -1,11 +1,11 @@
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
interface Props {
placeholder?: string | ((node: any) => string);
// Content of the editor
content?: Content;
content?: HTMLContent;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
@@ -21,7 +21,7 @@
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onChange?: (content: Content) => void;
onChange?: (content: HTMLContent) => void;
onReady?: (editor: TiptapEditor) => void;
onEditorDestroy?: () => void;
}
@@ -1,28 +0,0 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import { isFreePlan } from '$lib/utils/store/org';
interface Props {
className?: string;
onClick?: any;
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, children }: Props = $props();
</script>
{#if $isFreePlan}
<button
class="flex w-full items-center gap-2 rounded-md border border-blue-700 bg-blue-50 px-2 py-2 transition-all ease-in-out hover:scale-95 {className}"
onclick={() => {
onClick();
openUpgradeModal();
}}
>
<ZapIcon size={16} class="filled" />
<p class="text-sm font-semibold text-blue-700">
{@render children?.()}
</p>
</button>
{/if}
@@ -1,227 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Progress } from '@cio/ui/base/progress';
import CheckIcon from '@lucide/svelte/icons/check';
import { PLANS } from '@cio/utils/plans';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import Modal from '$lib/components/Modal/index.svelte';
import Confetti from '$lib/components/Confetti/index.svelte';
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
const disabledClass = 'bg-gray-300 text-gray-400 hover:cursor-not-allowed';
const planNames = Object.keys(PLANS);
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(false);
const query = $derived(new URLSearchParams(page.url.search));
let open = $derived((query.get('upgrade') || '') === 'true');
let isConfirming = $derived((query.get('confirmation') || '') === 'true');
async function handleClick(plan, planName: string) {
if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) {
return;
}
if (!plan.CTA.PRODUCT_ID && plan.CTA.DASHBOARD_LINK) {
window.open(plan.CTA.DASHBOARD_LINK, '_blank');
return;
}
isLoadingPlan = planName;
try {
const checkoutURL = new URL('/api/polar/subscribe', page.url);
checkoutURL.searchParams.set('productId', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID);
checkoutURL.searchParams.set('customerEmail', $profile.email);
checkoutURL.searchParams.set('customerName', $profile.fullname);
checkoutURL.searchParams.set(
'metadata',
JSON.stringify({
triggeredBy: $currentOrg.roleId,
orgId: $currentOrg.id,
orgSlug: $currentOrg.siteName
})
);
window.location.href = checkoutURL.toString();
} catch (error) {
console.error('Error subscribing', error);
snackbar.error('snackbar.upgrade.failed');
}
isLoadingPlan = null;
}
function onUpgrade() {
untrack(() => {
upgraded = true;
});
toggleConfetti();
setTimeout(() => {
toggleConfetti();
}, 1000);
}
function onClose() {
const path = upgraded ? $currentOrgPath : page.url.pathname;
goto(path);
}
function onLearnMore() {
window.open('https://classroomio.com/blog/early-adopter', '_blank');
}
function toggleIsYearlyPlan() {
isYearlyPlan = !isYearlyPlan;
}
function handleUpgradeSuccess(upgradeSuccessful: boolean) {
if (upgradeSuccessful) {
onUpgrade();
}
}
$effect(() => {
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
});
</script>
{#if upgraded}
<Confetti />
{/if}
<Modal
{onClose}
{open}
width="w-4/5"
maxWidth={upgraded || isConfirming ? 'max-w-[600px]' : undefined}
modalHeading={$t('pricing.modal.heading')}
containerClass="pt-4"
>
{#if upgraded}
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
<StepDoneIcon />
<h4 class="text-2xl">{$t('pricing.modal.thanks')}</h4>
<p class="mb-4 text-center">
{$t('pricing.modal.plan')}
</p>
<div class="flex items-center gap-4">
<PrimaryButton label={$t('pricing.modal.close')} variant={VARIANTS.OUTLINED} onClick={onClose} />
<PrimaryButton label={$t('pricing.modal.learn')} variant={VARIANTS.CONTAINED_DARK} onClick={onLearnMore} />
</div>
</div>
{:else if isConfirming}
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
<Progress />
</div>
{:else}
<div class="my-2 flex h-full flex-col items-center justify-center">
<div class="relative mb-2 flex items-center rounded-[30px] border p-1">
<button
style="background-color: {isYearlyPlan ? 'initial' : '#1D4EE2'}; color: {isYearlyPlan ? '#5e636b' : '#fff'}"
class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
onclick={toggleIsYearlyPlan}
>
{$t('pricing.modal.monthly')}
</button>
<button
style="background-color: {isYearlyPlan ? '#1D4EE2' : ''}; color: {isYearlyPlan ? '#fff' : '#5e636b'}"
class="relative rounded-[30px] px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
onclick={toggleIsYearlyPlan}
>
{$t('pricing.modal.annually')}
<div class="absolute -top-4 right-[-40%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white">
{$t('pricing.modal.save')}
</div>
</button>
</div>
<div class="isolate grid grid-cols-1 gap-3 overflow-y-scroll p-2 md:overflow-y-hidden lg:grid-cols-3">
{#each planNames as planName}
<div
class="max-w-xl rounded-xl {planName === 'EARLY_ADOPTER' &&
'cio-bg-blue'} p-4 ring-1 ring-gray-200 lg:max-w-sm"
>
<p
class="mb-2 text-lg font-semibold leading-8 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-gray-900'} lg:text-xl dark:text-white"
>
{PLANS[planName]?.NAME}
</p>
<p
class="text-baseline flex items-baseline gap-x-1 font-medium {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} dark:text-gray-300"
>
{PLANS[planName]?.PRICE?.CURRENCY}
{isYearlyPlan ? PLANS[planName]?.PRICE?.YEARLY : PLANS[planName]?.PRICE?.MONTHLY}
</p>
<p
class=" mt-4 text-sm font-light leading-6 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} lg:leading-6 dark:text-gray-300"
>
{PLANS[planName]?.DESCRIPTION}
</p>
<button
class="text-md mt-4 block w-full rounded-md {isLoadingPlan === planName && disabledClass} {planName ===
'EARLY_ADOPTER'
? 'bg-white text-slate-900 hover:bg-indigo-50'
: PLANS[planName]?.CTA?.IS_DISABLED
? disabledClass
: 'bg-primary-900 hover:bg-primary-700 text-white'} flex items-center justify-center py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold"
onclick={() => {
if (isLoadingPlan === planName) return;
handleClick(PLANS[planName], planName);
}}
>
{#if isLoadingPlan === planName}
<Progress />
{:else}
{PLANS[planName]?.CTA?.DASHBOARD_LABEL}
{/if}
</button>
<ul
class="mt-4 space-y-2 text-sm {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} dark:text-gray-300"
>
{#each PLANS[planName]?.FEATURES as features}
<li class="flex items-center">
<div>
<CheckIcon
class="custom mr-2 size-4 lg:mr-3 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-[#1D4EE2]'}"
/>
</div>
<p class="text-sm">
{features}
</p>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
{/if}
</Modal>
@@ -3,7 +3,6 @@
import { onMount } from 'svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import TabContent from '$lib/components/TabContent/index.svelte';
import { snackbar } from '../Snackbar/store';
import Modal from '$lib/components/Modal/index.svelte';
@@ -11,6 +10,7 @@
import { queryUnsplash } from './utils';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
interface Props {
imageURL?: string;
@@ -19,8 +19,6 @@
let { imageURL = $bindable(''), onchange }: Props = $props();
const supabase = getSupabase();
const tabs = [
{ label: 'Unsplash', value: 'unsplash' },
{ label: 'Upload', value: 'upload' }
@@ -65,28 +63,20 @@
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
uploadImage(file);
handleUploadImage(file);
};
}
};
const uploadImage = async (image: File) => {
const handleUploadImage = async (image: File) => {
isUploading = true;
if (!image) {
return;
}
const filename = `uploadwidget/${Date.now()}` + image.name;
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
imageURL = response.publicUrl;
imageURL = await uploadImage(image);
onchange?.(response.publicUrl);
}
onchange?.(imageURL);
isUploading = false;
snackbar.success(`snackbar.landing_page_settings.success.complete`);
@@ -1,13 +1,13 @@
import type { AccountOrg } from '$lib/features/app/types';
import type { Cookies } from '@sveltejs/kit';
import type { CurrentOrg } from '$lib/utils/types/org';
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { env } from '$env/dynamic/private';
import { getCurrentOrg } from '$lib/utils/services/org';
export interface OrgSiteInfo {
isOrgSite: boolean;
org: CurrentOrg | null;
org: AccountOrg | null;
subdomain: string;
orgSiteName: string;
}
@@ -26,14 +26,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Student dashboard
if (subdomain) {
const org = (await getCurrentOrg(subdomain, true)) || null;
const org = await OrgApiServer.getOrgBySiteName(subdomain);
// Organization by subdomain not found
if (!org) {
return response;
}
response.org = org;
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = subdomain;
response.subdomain = subdomain;
@@ -59,12 +59,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Custom domain
if (isURLCustomDomain(url)) {
response.org = (await getCurrentOrg(url.host, true, true)) || null;
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
if (!response.org) {
if (!orgs || orgs.length === 0) {
return response;
}
const org = orgs[0];
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = response.org?.siteName || '';
response.subdomain = subdomain;
@@ -82,7 +84,11 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
response.isOrgSite = debugMode || !!subdomain;
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
if (response.orgSiteName) {
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
response.org = (org as AccountOrg) || null;
}
const shouldDeleteCookie = !response.org && _orgSiteName;
if (shouldDeleteCookie) {
@@ -0,0 +1 @@
// Components exports will be added here when needed
@@ -0,0 +1,133 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$lib/features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Page from '@cio/ui/base/page';
$effect(() => {
orgApi.getOrgAudience($currentOrg.id);
});
const headers = [
{ key: 'name', value: $t('audience.name') },
{ key: 'email', value: $t('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') }
];
let pageSize = $state(5);
let currentPage = $state(1);
let searchValue = $state('');
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Page.BodyHeader>
<Search placeholder="Search..." bind:value={searchValue} class="" />
</Page.BodyHeader>
{#if orgApi.audience.length || orgApi.isLoading}
<div class="w-full space-y-4">
<!-- Table -->
{#if orgApi.isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline"
>
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.date_joined}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between">
<Pagination.Root
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
{/if}
@@ -0,0 +1,2 @@
export { default as AudiencePage } from './audience.svelte';
export { default as UserAnalyticsPage } from './user-analytics.svelte';
@@ -0,0 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import Progress from '$lib/components/Progress/index.svelte';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
let userMetrics = $derived([
{
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length || 0,
hidePercentage: true
},
{
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress || 0
},
{
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade || 0
}
]);
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity}
<ActivityCard {activity} />
{/each}
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedCourses,
total: userAnalytics!.courses.length
})}
</p>
</div>
<Progress value={userAnalytics!.overallCourseProgress} />
<div class="flex items-center justify-between">
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
>
{completedCourses}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredCourses as course, index}
{#key index}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/key}
{/each}
</div>
</div>
{:else}
<LoadingPage />
{/if}
@@ -4,13 +4,12 @@ import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account'
import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account';
import { authClient } from '$lib/utils/services/auth/client';
import generateUUID from '$lib/utils/functions/generateUUID';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
avatar?: string | File | undefined;
@@ -37,24 +36,6 @@ export class ProfileApi extends BaseApiWithErrors {
return true;
}
private async uploadAvatar(avatar: File): Promise<string | undefined> {
const filename = `user/${generateUUID()}.webp`;
const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
if (uploadData) {
const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename);
return urlData.publicUrl;
}
return undefined;
}
private async updateUserInfo(
fields: TProfileUpdateForm,
currentProfile: TProfile,
@@ -124,11 +105,11 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
this.errors.username = 'snackbar.lms.error.username_exists';
this.errors.username = t.get('snackbar.lms.error.username_exists');
snackbar.error('snackbar.lms.error.username_exists');
} else {
this.errors.general = message;
snackbar.error(`snackbar.lms.error.update: ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
}
@@ -145,7 +126,7 @@ export class ProfileApi extends BaseApiWithErrors {
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await this.uploadAvatar(fields.avatar);
avatarUrl = await uploadImage(fields.avatar);
}
// Update user info via Better Auth (name and image)
@@ -185,7 +166,7 @@ export class ProfileApi extends BaseApiWithErrors {
const { error } = await authClient.changeEmail({
newEmail: fields.newEmail,
callbackURL: window.location.origin
callbackURL: fields.callbackURL
});
if (error) throw new Error(error.message);
@@ -197,7 +178,7 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
this.errors.email = message;
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
} finally {
this.isLoading = false;
}
@@ -0,0 +1,22 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
{#if isMobileStore.current}
<Button onclick={askCommunity}>
<PlusIcon size={16} />
</Button>
{:else}
<Button onclick={askCommunity}>
{$t('community.ask_button')}
</Button>
{/if}
@@ -0,0 +1,22 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
</div>
<div class="flex w-9/12 flex-col gap-2 rounded-md">
<!-- first text -->
<Skeleton class="h-4 w-3/6 rounded-sm" />
<!-- second text -->
<Skeleton class="h-4 w-1/4 rounded-sm" />
</div>
<div class="ml-auto w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-4 w-full rounded-sm" />
</div>
</div>
@@ -0,0 +1,3 @@
export { default as CommunityListLoader } from './community-list-loader.svelte';
export { default as CommunityDeleteModal } from './delete-modal.svelte';
export { default as AskCommunityButton } from './ask-community-button.svelte';
@@ -1,23 +1,26 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import Search from '@lucide/svelte/icons/search';
import { Search } from '@cio/ui/custom/search';
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import CommunityLoader from './Loader.svelte';
import Box from '$lib/components/Box/index.svelte';
import { CommunityListLoader } from '../components';
import Vote from '$lib/components/Vote/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import { AskCommunityButton } from '../components';
interface Props {
isLMS?: boolean;
@@ -105,42 +108,34 @@
);
</script>
<div class="flex w-full gap-2">
<div class="relative flex-1">
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder={$t('community.find_question')}
bind:value={searchValue}
class="bg-gray-100 dark:bg-neutral-800"
/>
</div>
<div class="w-[25%]">
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
<p class="truncate">
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
</p>
</Select.Trigger>
<Select.Content>
<Select.Item value="">{$t('community.all')}</Select.Item>
{#each allCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div
class="border-c m-auto my-4 flex flex-wrap items-center justify-center rounded bg-gray-100 lg:justify-start dark:bg-neutral-800"
>
{#if isLoading}
<CommunityLoader />
<CommunityLoader />
<CommunityLoader />
<CommunityLoader />
{:else}
{#each filteredDiscussions as discussion}
<Page.BodyHeader>
<Search placeholder={$t('community.find_question')} bind:value={searchValue} />
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
<p class="truncate">
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
</p>
</Select.Trigger>
<Select.Content>
<Select.Item value="">{$t('community.all')}</Select.Item>
{#each allCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Page.BodyHeader>
{#if isLoading}
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
{:else}
{#each filteredDiscussions as discussion}
<div
class="border-c m-auto my-4 flex flex-wrap items-center justify-center rounded bg-gray-100 lg:justify-start dark:bg-neutral-800"
>
<div class="border-bottom-c flex w-full p-3">
<Vote value={discussion.votes} />
<div class="flex flex-col gap-y-0.5 text-sm">
@@ -164,15 +159,18 @@
<span class="ml-1">{discussion.comments}</span>
</div>
</div>
{:else}
<Box className="">
<CoursesEmptyIcon size={16} />
<h3 class="dark:text-white text-2xl my-5">{$t('community.no_question')}</h3>
<p class="dark:text-white w-1/3 text-center">{$t('community.ask_a_question')}</p>
</Box>
{/each}
{/if}
</div>
</div>
{:else}
<Empty
title={$t('community.no_question')}
description={$t('community.ask_a_question')}
icon={MessageSquareMoreIcon}
variant="page"
>
<AskCommunityButton />
</Empty>
{/each}
{/if}
<style>
h4 {
@@ -0,0 +1 @@
export { default as CommunityPage } from './community.svelte';
@@ -2,7 +2,7 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border bg-gray-100 p-2 transition ease-in-out dark:bg-black">
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<!-- Title -->
@@ -15,7 +15,7 @@
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
import { copyCourseModal, deleteCourseModal } from '$lib/features/course/utils/store';
interface Props {
bannerImage: string | undefined;
@@ -7,7 +7,7 @@
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/components/Courses/store';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button, type ButtonProps } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import PlusIcon from '@lucide/svelte/icons/plus';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
let {
variant,
isResponsive = false
}: ButtonProps & {
isResponsive?: boolean;
} = $props();
function onClick() {
goto(`${$currentOrgPath}/courses?create=true`);
}
</script>
{#if isResponsive && isMobileStore.current}
<Button variant="outline" size="icon" disabled={!$isOrgAdmin} onclick={onClick}>
<PlusIcon size={16} />
</Button>
{:else}
<Button {variant} onclick={onClick} disabled={!$isOrgAdmin}>{$t('courses.heading_button')}</Button>
{/if}
@@ -0,0 +1,7 @@
export { default as CourseCard } from './card.svelte';
export { default as CourseCardLoader } from './card-loader.svelte';
export { default as CopyCourseModal } from './copy-course-modal.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte';
@@ -5,7 +5,7 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobile } from '$lib/utils/store/useMobile';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -58,7 +58,7 @@
<Table.Cell class="truncate">
<p>{description}</p>
</Table.Cell>
{#if !$isMobile}
{#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
@@ -4,8 +4,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ComingSoon } from '$lib/features/ui';
import { validateForm } from '$lib/components/Courses/functions';
import { courses, createCourseModal } from '$lib/components/Courses/store';
import { validateForm } from '../utils/functions';
import { courses, createCourseModal } from '../utils/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
@@ -1,36 +1,72 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { Search } from '@cio/ui/custom/search';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { IconButton } from '$lib/components/IconButton';
import {
CourseCard,
CourseCardLoader,
CreateCourseButton,
CourseList,
CopyCourseModal,
NewCourseModal
} from '$lib/features/course/components';
import Box from '../Box/index.svelte';
import Card from './components/Card/index.svelte';
import List from './components/List/index.svelte';
import CardLoader from './components/Card/Loader.svelte';
import CoursesEmptyIcon from '../Icons/CoursesEmptyIcon.svelte';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
import { courseMetaDeta } from './store';
import type { Course } from '$lib/utils/types';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourse } from '$lib/utils/services/courses';
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
import {
deleteCourseModal,
deleteCourseModalInitialState,
courses as coursesStore,
courseMetaDeta
} from '../utils/store';
import { browser } from '$app/environment';
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
isExplore?: boolean;
searchValue?: string;
selectedId?: string;
}
let {
courses = [],
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false
isExplore = false,
searchValue = $bindable(''),
selectedId = $bindable('0')
}: Props = $props();
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.published') },
{ value: '2', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
);
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
if (browser) {
localStorage.setItem('courseView', preference);
}
};
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
@@ -65,20 +101,50 @@
}
</script>
<NewCourseModal />
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<div class="mx-auto my-4 w-full">
<Page.BodyHeader align="right">
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
{#if $courseMetaDeta.isLoading}
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</section>
{:else if !courses.length}
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
<CreateCourseButton isResponsive />
</Empty>
{:else if $courseMetaDeta.view === 'list'}
<div class="w-full overflow-hidden rounded-md border">
<Table.Root class="w-full table-fixed">
@@ -95,7 +161,7 @@
</Table.Header>
<Table.Body>
{#each courses as courseData}
<List
<CourseList
id={courseData.id}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
@@ -112,7 +178,7 @@
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
{#each courses as courseData}
{#key courseData.id}
<Card
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
@@ -132,12 +198,3 @@
</section>
{/if}
</div>
{#if !$courseMetaDeta.isLoading && !courses.length}
<Box className="w-full">
<CoursesEmptyIcon />
<h3 class="my-5 text-2xl dark:text-white">{emptyTitle}</h3>
<p class="w-1/3 text-center dark:text-white">
{emptyDescription}
</p>
</Box>
{/if}
@@ -0,0 +1 @@
export { default as CoursesPage } from './courses.svelte';
@@ -0,0 +1,77 @@
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
/**
* Updates an organization plan (server-side)
* @param params Update parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async updateOrgPlan(params: TUpdateOrgPlan) {
try {
const response = await classroomio.organization.plan.$put(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error updating org plan (server):', error);
return null;
}
}
/**
* Cancels an organization plan (server-side)
* @param params Cancel parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async cancelOrgPlan(params: TCancelOrgPlan) {
try {
const response = await classroomio.organization.plan.cancel.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error canceling org plan (server):', error);
return null;
}
}
}
@@ -1,9 +1,9 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { TNewOrganizationPlan } from '@cio/db/types';
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
/**
* API class for organization plan operations
* API class for organization plan operations (client-side)
* For server-side operations, use OrgPlanApiServer from './org-plan.server.ts'
*/
class OrgPlanApi extends BaseApi {
/**
@@ -11,7 +11,7 @@ class OrgPlanApi extends BaseApi {
* @param params Organization plan creation parameters
* @returns Created organization plan
*/
async createOrgPlan(params: TNewOrganizationPlan) {
async createOrgPlan(params: TCreateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$post({ json: params }),
logContext: 'creating organization plan'
@@ -23,7 +23,7 @@ class OrgPlanApi extends BaseApi {
* @param params Update parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async updateOrgPlan(params: TUpdateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$put({ json: params }),
logContext: 'updating organization plan'
@@ -35,7 +35,7 @@ class OrgPlanApi extends BaseApi {
* @param params Cancel parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async cancelOrgPlan(params: TCancelOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
logContext: 'canceling organization plan'
@@ -0,0 +1,57 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
/**
* Gets organizations by custom domain (server-side)
* @param customDomain Custom domain
* @param isCustomDomainVerified Whether the domain is verified
* @returns Array of organizations
*/
static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) {
try {
const response = await classroomio.organization.$get(
{
query: {
customDomain,
isCustomDomainVerified
}
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data ? data.data : [];
} catch (error) {
console.error('Error fetching organizations by custom domain (server):', error);
return [];
}
}
}
@@ -1,20 +1,45 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CoursesByOrganizationSiteName,
DeleteTeamResponse,
GetAudienceResponse,
GetCoursesBySiteNameResponse,
InviteTeamResponse,
OrganizationAudience,
OrganizationTeamMembers
} from '../utils/types';
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
import { currentOrg, orgs } from '$lib/utils/store/org';
import type { AccountOrg } from '$lib/features/app/types';
import type { GetTeamResponse } from '../utils/types';
import type { TGetOrganizations } from '@cio/utils/validation/organization';
import { orgs } from '$lib/utils/store/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import type { UpdateOrganizationRequestType } from './types';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TOrgUpdateForm {
name?: string;
avatar?: string | File | undefined;
theme?: string;
landingpage?: AccountOrg['landingpage'];
siteName?: string;
customDomain?: string | null;
isCustomDomainVerified?: boolean;
customization?: AccountOrg['customization'];
}
/**
* API class for organization operations
*/
class OrgApi extends BaseApi {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
@@ -32,7 +57,16 @@ class OrgApi extends BaseApi {
}),
logContext: 'fetching organization team',
onSuccess: (response) => {
this.teamMembers = response.data;
// Map API response to include role and isAdmin directly in the API layer
const mappedTeam = response.data.map((member) => ({
...member,
role: ROLE_LABEL[member.roleId] || '',
isAdmin: member.roleId === ROLE.ADMIN,
verified: member.verified ?? false
}));
// Update both API state and store
this.teamMembers = mappedTeam;
}
});
}
@@ -42,7 +76,9 @@ class OrgApi extends BaseApi {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId: string) {
async getOrgAudience(orgId?: string) {
if (!orgId) return;
return this.execute<GetAudienceResponse>({
requestFn: () =>
classroomio.organization[':orgId'].audience.$get({
@@ -103,6 +139,186 @@ class OrgApi extends BaseApi {
return result;
}
/**
* Creates a new organization with the current user as owner
* @param fields Organization creation data (name, siteName)
*/
async create(fields: TCreateOrganization) {
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
const createdOrg = organizations.find((org) => org.id === organization.id);
if (createdOrg) {
currentOrg.set(createdOrg);
}
snackbar.success('snackbar.success_update');
goto(resolve(`/org/${organization.siteName}`, {}));
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
// Field-specific error automatically mapped to this.errors[field]
this.errors[result.field as string] = result.error;
} else if ('error' in result) {
this.errors.general = result.error;
}
}
});
}
/**
* Updates an organization
* @param orgId Organization ID
* @param fields Organization update data (name, avatar, theme, landingpage)
*/
async update(orgId: string, fields: TOrgUpdateForm) {
const result = ZUpdateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
this.isLoading = true;
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await uploadImage(fields.avatar);
} else if (typeof fields.avatar === 'string') {
avatarUrl = fields.avatar;
}
// Build update payload
fields.avatar = undefined;
const updates: TUpdateOrganization = {
...fields,
avatarUrl
};
await this.execute<UpdateOrganizationRequestType>({
requestFn: () =>
classroomio.organization[':orgId'].$put({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: updates
}),
logContext: 'updating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
orgs.update((_orgs) =>
_orgs.map((org) => {
if (org.id === orgId) {
return { ...org, ...response.data };
}
return org;
})
);
const currentOrgData = get(currentOrg);
if (currentOrgData.id === orgId) {
currentOrg.update((org) => ({
...org,
...response.data
}));
}
snackbar.success('snackbar.course_settings.success.update_successful');
this.success = true;
this.errors = {};
},
onError: (error) => {
console.error('Error updating organization:', error);
const message = error instanceof Error ? error.message : `${error}`;
this.errors.general = message;
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
});
}
/**
* Invites team members to the organization
* @param orgId Organization ID
* @param emails Array of email addresses
* @param roleId Role ID (ADMIN or TUTOR)
*/
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
return this.execute<InviteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team.invite.$post({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: { emails, roleId }
}),
logContext: 'inviting team members',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
/**
* Removes a team member from the organization
* @param orgId Organization ID
* @param memberId Member ID to remove
*/
async removeTeamMember(orgId: string, memberId: number) {
return this.execute<DeleteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team[':memberId'].$delete({
param: { orgId, memberId: memberId.toString() }
}),
logContext: 'removing team member',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,5 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put'];
export type UpdateOrganizationResponse = InferResponseType<UpdateOrganizationRequestType>;
export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { success: true }>;
@@ -5,7 +5,16 @@ export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team'
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data'];
// Mapped team members with role and isAdmin (mapped in API layer)
export type OrganizationTeamMembers = Array<
OrganizationTeamMembersRaw[number] & {
role: string;
isAdmin: boolean;
verified: boolean;
}
>;
// Audience types
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
@@ -23,3 +32,10 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
>;
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamResponse>, { success: true }>;
@@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
sm?: number;
md?: number;
lg?: number;
class?: string;
children?: import('svelte').Snippet;
}
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
const getColSpan = (cols: number) => {
const spanMap = {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
4: 'col-span-4',
5: 'col-span-5',
6: 'col-span-6',
7: 'col-span-7',
8: 'col-span-8',
9: 'col-span-9',
10: 'col-span-10',
11: 'col-span-11',
12: 'col-span-12',
13: 'col-span-13',
14: 'col-span-14',
15: 'col-span-15',
16: 'col-span-16'
};
return spanMap[cols] || 'col-span-full';
};
const smClass = getColSpan(sm);
const mdClass = `md:${getColSpan(md)}`;
const lgClass = `lg:${getColSpan(lg)}`;
</script>
<div class="{smClass} {mdClass} {lgClass} {className} grid">
{@render children?.()}
</div>
@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
class?: string;
children?: import('svelte').Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class="w-full {className}">
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
id?: string;
class?: string;
children?: import('svelte').Snippet;
}
let { id, class: className = '', children }: Props = $props();
</script>
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
{@render children?.()}
</div>
@@ -0,0 +1,3 @@
export { default as Grid } from './Grid.svelte';
export { default as Row } from './Row.svelte';
export { default as Column } from './Column.svelte';

Some files were not shown because too many files have changed in this diff Show More