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:
@@ -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 }}
|
||||
@@ -45,3 +45,7 @@ vite.config.ts*.mjs
|
||||
.github/instructions/codacy.instructions.md
|
||||
|
||||
tsconfig.tsbuildinfo*
|
||||
|
||||
storybook-static
|
||||
|
||||
.svelte-kit/
|
||||
+808
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './media';
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from './account';
|
||||
export * from './media';
|
||||
export * from './onboarding';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1,4 +1,6 @@
|
||||
/*Write your global styles here, in PostCSS syntax */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&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;
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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}
|
||||
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+5
-4
@@ -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
-5
@@ -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="musk@x.com"
|
||||
/>
|
||||
<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="musk@x.com"
|
||||
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';
|
||||
+48
-50
@@ -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';
|
||||
+1
-1
@@ -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 -->
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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';
|
||||
+2
-2
@@ -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>
|
||||
+2
-2
@@ -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';
|
||||
+82
-25
@@ -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
Reference in New Issue
Block a user