feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
**/dist/
|
||||
**/.next/
|
||||
**/out/
|
||||
**/build/
|
||||
**/.turbo/
|
||||
**/.svelte-kit/
|
||||
|
||||
# Testing
|
||||
**/coverage/
|
||||
cypress/downloads/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Generated files
|
||||
apps/api/dist/
|
||||
packages/utils/dist/
|
||||
packages/course-app/dist/
|
||||
packages/db/dist/
|
||||
|
||||
# Vite temp files
|
||||
vite.config.ts*.mjs
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
@@ -43,3 +43,5 @@ vite.config.ts*.mjs
|
||||
|
||||
#Ignore vscode AI rules
|
||||
.github/instructions/codacy.instructions.md
|
||||
|
||||
tsconfig.tsbuildinfo*
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"MD013": false,
|
||||
"MD024": { "siblings_only": true }
|
||||
}
|
||||
+2
-1
@@ -4,4 +4,5 @@ build
|
||||
.turbo
|
||||
_template
|
||||
supabase
|
||||
static
|
||||
static
|
||||
dist
|
||||
Vendored
+26
-1
@@ -21,5 +21,30 @@
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.tsserver.maxTsServerMemory": 4072,
|
||||
"typescript.disableAutomaticTypeAcquisition": false,
|
||||
"typescript.tsserver.experimental.enableProjectDiagnostics": false,
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/.hg/store/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.next/**": true,
|
||||
"**/.svelte-kit/**": true,
|
||||
"**/build/**": true,
|
||||
"**/.turbo/**": true,
|
||||
"**/coverage/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/.next": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/build": true,
|
||||
"**/.turbo": true,
|
||||
"**/coverage": true
|
||||
// "pnpm-lock.yaml": true
|
||||
}
|
||||
}
|
||||
|
||||
+1626
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
.env*
|
||||
.DS_Store
|
||||
dist
|
||||
dist
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -1,18 +0,0 @@
|
||||
# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "classroomio-server"
|
||||
primary_region = "lhr"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3002
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = ["app"]
|
||||
@@ -4,8 +4,11 @@
|
||||
"description": "ClassroomIO Backend for long processes",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && tsc-alias",
|
||||
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc --build && tsc-alias",
|
||||
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
|
||||
"dev:server": "tsx watch src/index.ts",
|
||||
"dev:tsc": "tsc -p tsconfig.json --watch",
|
||||
@@ -36,6 +39,9 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.388.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
||||
"@cio/db": "workspace:*",
|
||||
"@cio/email": "workspace:*",
|
||||
"@cio/utils": "workspace:*",
|
||||
"@hono/node-server": "^1.8.2",
|
||||
"@hono/standard-validator": "^0.1.5",
|
||||
"@hono/zod-validator": "^0.7.2",
|
||||
@@ -43,6 +49,7 @@
|
||||
"@sentry/profiling-node": "^7.110.1",
|
||||
"@supabase/supabase-js": "^2.32.0",
|
||||
"axios": "^1.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"hono": "^4.9.7",
|
||||
"hono-openapi": "^1.0.8",
|
||||
@@ -51,15 +58,13 @@
|
||||
"katex": "^0.16.10",
|
||||
"marked": "^7.0.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"zeptomail": "^6.0.0",
|
||||
"zod": "^4.1.8",
|
||||
"zod-openapi": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||
"@typescript-eslint/parser": "7.0.1",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
|
||||
+42
-6
@@ -1,12 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { accountRouter } from '@api/routes/account';
|
||||
import { auth } from '@cio/db/auth';
|
||||
import { cors } from 'hono/cors';
|
||||
import { courseRouter } from '@api/routes/course/course';
|
||||
import { courseRouter } from '@api/routes/course';
|
||||
import { logger } from 'hono/logger';
|
||||
import { mailRouter } from '@api/routes/mail';
|
||||
import { onboardingRouter } from '@api/routes/onboarding';
|
||||
import { organizationRouter } from '@api/routes/organization';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import { rateLimiterMiddleware } from '@api/middlewares/rate-limiter';
|
||||
import rateLimiter from '@api/middlewares/rate-limiter';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
@@ -18,7 +24,7 @@ export const app = new Hono()
|
||||
.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: '*',
|
||||
origin: TRUSTED_ORIGINS,
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
||||
@@ -26,16 +32,46 @@ export const app = new Hono()
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
.use('*', rateLimiterMiddleware)
|
||||
.use('*', rateLimiter)
|
||||
.use('*', async (c, next) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
|
||||
if (!session) {
|
||||
c.set('user', null);
|
||||
c.set('session', null);
|
||||
await next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
c.set('user', session.user);
|
||||
c.set('session', session.session);
|
||||
await next();
|
||||
})
|
||||
|
||||
// Routes
|
||||
.get('/', (c) =>
|
||||
c.json({
|
||||
message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"'
|
||||
message: `"Welcome to Classroomio.com API - docs are at ${API_SERVER_URL}/docs"`
|
||||
})
|
||||
)
|
||||
.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
|
||||
.get('/session', async (c) => {
|
||||
const session = c.get('session');
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user) return c.body(null, 401);
|
||||
|
||||
return c.json({
|
||||
session,
|
||||
user
|
||||
});
|
||||
})
|
||||
.route('/onboarding', onboardingRouter)
|
||||
.route('/account', accountRouter)
|
||||
.route('/course', courseRouter)
|
||||
.route('/mail', mailRouter)
|
||||
.route('/organization', organizationRouter)
|
||||
|
||||
// Error handling
|
||||
.onError((err, c) => {
|
||||
|
||||
@@ -9,16 +9,18 @@ const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
OPENAPI_URL: z.string().optional(),
|
||||
PORT: z.string().optional(),
|
||||
PRIVATE_SUPABASE_SERVICE_ROLE: z.string(),
|
||||
PUBLIC_SUPABASE_ANON_KEY: z.string(),
|
||||
PUBLIC_SUPABASE_URL: z.string(),
|
||||
PRIVATE_SUPABASE_SERVICE_ROLE: z.string(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
SERVER_URL: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_SENDER: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
ZOHO_TOKEN: z.string().optional()
|
||||
ZOHO_TOKEN: z.string().optional(),
|
||||
TRUSTED_ORIGINS: z.string().optional()
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -2,6 +2,12 @@ import { env } from '@api/config/env';
|
||||
|
||||
export * from './rate-limiter';
|
||||
|
||||
export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
|
||||
export const API_SERVER_URL = env.SERVER_URL || `http://localhost:${API_PORT}`;
|
||||
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
|
||||
? env.TRUSTED_ORIGINS.split(',')
|
||||
: ['http://localhost:5173', 'http://*.classroomio.*'];
|
||||
|
||||
const {
|
||||
CLOUDFLARE_BUCKET_DOMAIN,
|
||||
CLOUDFLARE_ACCESS_KEY,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { API_PORT } from '@api/constants';
|
||||
import { app } from '@api/app';
|
||||
import { configureOpenAPI } from '@api/utils/openapi';
|
||||
import { env } from '@api/config/env';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { showRoutes } from 'hono/dev';
|
||||
|
||||
// Start server
|
||||
const port = env.PORT ? parseInt(env.PORT) : 3002;
|
||||
|
||||
function startServer() {
|
||||
console.log('Starting server on port:', port);
|
||||
console.log('Starting server on port:', API_PORT);
|
||||
|
||||
serve({ fetch: app.fetch, port });
|
||||
serve({ fetch: app.fetch, port: API_PORT });
|
||||
|
||||
showRoutes(app, { colorize: true });
|
||||
}
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { validateUser } from '@api/utils/auth/validate-user';
|
||||
|
||||
export const authMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const authHeader = c.req.header('authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No authorization header provided'
|
||||
},
|
||||
401
|
||||
);
|
||||
if (!c.get('user') || !c.get('session')) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Extract the token from "Bearer <token>"
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid authorization header format'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const user = await validateUser(token);
|
||||
c.set('user', user); // Attach the user to the context
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
|
||||
@@ -76,4 +76,4 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
|
||||
};
|
||||
|
||||
// Default rate limiter middleware with standard configuration
|
||||
export const rateLimiterMiddleware = createRateLimiter();
|
||||
export default createRateLimiter();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getAccountData, updateUser } from '@api/services/account';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZUpdateProfile } from '@cio/utils/validation/account';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const accountRouter = new Hono()
|
||||
.get('/', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
try {
|
||||
console.time('accountRouter');
|
||||
const accountData = await getAccountData(user.id);
|
||||
console.timeEnd('accountRouter');
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
user,
|
||||
profile: accountData.profile,
|
||||
organizations: accountData.organizations
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch account data');
|
||||
}
|
||||
})
|
||||
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
try {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const updatedProfile = await updateUser(user.id, validatedData);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
profile: updatedProfile
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update profile');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account';
|
||||
@@ -1,42 +1,32 @@
|
||||
import { Hono } from 'hono';
|
||||
import { ZCourseClone, TCourseClone } from '@api/types/course';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { cloneCourse } from '@api/services/course/clone';
|
||||
import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZCourseClone } from '@cio/utils/validation/course';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import type { CloneCourseResponse, CloneCourseErrorResponse } from '@api/types/database';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import { cloneCourse } from '@api/services/course/clone';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
type Variables = {
|
||||
user: User;
|
||||
};
|
||||
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
|
||||
try {
|
||||
const validatedData = c.req.valid('json');
|
||||
const { id, title, description, slug, organizationId } = validatedData;
|
||||
|
||||
export const cloneRouter = new Hono<{ Variables: Variables }>().post(
|
||||
'/',
|
||||
authMiddleware,
|
||||
zValidator('json', ZCourseClone),
|
||||
async (c) => {
|
||||
try {
|
||||
const validatedData = (c.req.valid as (key: 'json') => TCourseClone)('json');
|
||||
const { id, title, description, slug, organizationId } = validatedData;
|
||||
const user = c.get('user');
|
||||
|
||||
// Get user from context (set by authMiddleware)
|
||||
const user = c.get('user');
|
||||
// Clone the course
|
||||
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
|
||||
|
||||
// Clone the course
|
||||
const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId);
|
||||
|
||||
const successResponse: CloneCourseResponse = {
|
||||
success: true,
|
||||
course: newCourse
|
||||
};
|
||||
return c.json(successResponse, 200);
|
||||
} catch (error) {
|
||||
console.error('Error cloning course:', error);
|
||||
const errorResponse: CloneCourseErrorResponse = {
|
||||
error: 'Failed to clone course',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
return c.json(errorResponse, 500);
|
||||
}
|
||||
const successResponse: CloneCourseResponse = {
|
||||
success: true,
|
||||
course: newCourse
|
||||
};
|
||||
return c.json(successResponse, 200);
|
||||
} catch (error) {
|
||||
console.error('Error cloning course:', error);
|
||||
const errorResponse: CloneCourseErrorResponse = {
|
||||
error: 'Failed to clone course',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
return c.json(errorResponse, 500);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { Hono } from 'hono';
|
||||
import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course';
|
||||
import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { cloneRouter } from '@api/routes/course/clone';
|
||||
import { generateCertificate } from '@api/utils/certificate';
|
||||
import { generateCoursePdf } from '@api/utils/course';
|
||||
import { katexRouter } from '@api/routes/course/katex';
|
||||
import { lessonRouter } from '@api/routes/course/lesson';
|
||||
import { presignRouter } from '@api/routes/course/presign';
|
||||
import { cloneRouter } from '@api/routes/course/clone';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const courseRouter = new Hono()
|
||||
.post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => {
|
||||
const validatedData = (c.req.valid as (key: 'json') => TCertificateDownload)('json');
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const result = await generateCertificate(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
|
||||
);
|
||||
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
|
||||
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
@@ -31,7 +28,7 @@ export const courseRouter = new Hono()
|
||||
);
|
||||
})
|
||||
.post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => {
|
||||
const validatedData = (c.req.valid as (key: 'json') => TCourseDownloadContent)('json');
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateCoursePdf(validatedData);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course';
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Context, Hono } from 'hono';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
|
||||
import katex from 'katex';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const katexRouter = new Hono().get('/', async (c: Context) => {
|
||||
export const katexRouter = new Hono().get('/', async (c) => {
|
||||
try {
|
||||
const original = c.req.raw.url;
|
||||
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
|
||||
import { generateLessonPdf } from '@api/utils/lesson';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const lessonRouter = new Hono().post(
|
||||
'/download/pdf',
|
||||
zValidator('json', ZLessonDownloadContent),
|
||||
async (c) => {
|
||||
const validatedData = (c.req.valid as (key: 'json') => TLessonDownloadContent)('json');
|
||||
export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const pdfBuffer = await generateLessonPdf(validatedData);
|
||||
const pdfBuffer = await generateLessonPdf(validatedData);
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
return c.body(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(pdfBuffer);
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
ZCourseDocumentPresignUrlUpload,
|
||||
ZCourseDownloadPresignedUrl,
|
||||
ZCoursePresignUrlUpload
|
||||
} from '@api/types/course';
|
||||
} from '@cio/utils/validation/course';
|
||||
import { describeRoute, validator } from 'hono-openapi';
|
||||
|
||||
import { BUCKET_NAME } from '@api/constants/upload';
|
||||
import { CLOUDFLARE } from '@api/constants';
|
||||
import type { GetSignedUrlParameters } from '@api/utils/s3';
|
||||
import { Hono } from 'hono';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { sendWithNodemailer, sendWithZoho } from '@api/services/mail';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { ZSendEmailValidation } from '@api/types/mail';
|
||||
import { env } from '@api/config/env';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const mailRouter = new Hono().post(
|
||||
'/send',
|
||||
zValidator('json', ZSendEmailValidation),
|
||||
async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
const results = await Promise.all(
|
||||
validatedData.map(async (emailData) => {
|
||||
try {
|
||||
const res = env.ZOHO_TOKEN
|
||||
? await sendWithZoho(emailData)
|
||||
: await sendWithNodemailer(emailData);
|
||||
|
||||
console.log('Email status:', res);
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const hasErrors = results.some((result) => !result.success);
|
||||
if (hasErrors) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some emails failed to send',
|
||||
details: results
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true, details: results });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mail';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZDeliverEmail } from '@cio/utils/validation/mail';
|
||||
import { deliverEmail } from '@cio/email';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => {
|
||||
const validatedData = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Use service to send emails
|
||||
const results = await deliverEmail(validatedData);
|
||||
|
||||
const hasErrors = results.some((result) => !result.success);
|
||||
if (hasErrors) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some emails failed to send',
|
||||
code: 'EMAIL_SEND_PARTIAL_FAILURE',
|
||||
details: results
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: results }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './onboarding';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
|
||||
import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const onboardingRouter = new Hono()
|
||||
.post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
const { fullname, orgName, siteName } = c.req.valid('json');
|
||||
|
||||
const result = await createOrganizationWithOwner(user.id, { fullname, orgName, siteName });
|
||||
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create organization');
|
||||
}
|
||||
})
|
||||
.post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
const { goal, source, fullname } = c.req.valid('json');
|
||||
|
||||
const result = await updateUserOnboarding(user.id, { goal, source, fullname });
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update onboarding data');
|
||||
}
|
||||
})
|
||||
.post('/complete', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
|
||||
await completeOnboarding(user);
|
||||
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to complete onboarding');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './organization';
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ZCancelOrgPlan,
|
||||
ZCreateOrgPlan,
|
||||
ZGetCoursesBySiteName,
|
||||
ZGetOrganizationAudience,
|
||||
ZGetOrganizationTeam,
|
||||
ZGetOrganizations,
|
||||
ZUpdateOrgPlan
|
||||
} from '@cio/utils/validation/organization';
|
||||
import {
|
||||
cancelOrgPlan,
|
||||
createOrgPlan,
|
||||
getCoursesByOrgSiteName,
|
||||
getOrgAudience,
|
||||
getOrgTeam,
|
||||
getOrganizationsWithFilters,
|
||||
updateOrgPlan
|
||||
} from '@api/services/organization';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const organizationRouter = new Hono()
|
||||
/**
|
||||
* GET /organization
|
||||
* Gets organizations with optional filters
|
||||
* Query params: siteName (string, optional), customDomain (string, optional), isCustomDomainVerified (boolean, optional)
|
||||
*/
|
||||
.get('/', zValidator('query', ZGetOrganizations), async (c) => {
|
||||
try {
|
||||
const filters = c.req.valid('query');
|
||||
const organizations = await getOrganizationsWithFilters(filters);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: organizations
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch organizations');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* GET /organization/:orgId/team
|
||||
* Gets organization team members (non-students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
|
||||
try {
|
||||
const { orgId } = c.req.valid('param');
|
||||
const team = await getOrgTeam(orgId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: team
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch organization team');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* GET /organization/:orgId/audience
|
||||
* Gets organization audience (students)
|
||||
* Requires authentication
|
||||
*/
|
||||
.get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => {
|
||||
try {
|
||||
const { orgId } = c.req.valid('param');
|
||||
const audience = await getOrgAudience(orgId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: audience
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch organization audience');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* GET /organization/courses
|
||||
* Gets courses by organization siteName
|
||||
* Query params: siteName (string)
|
||||
*/
|
||||
.get('/courses', 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');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/plan
|
||||
* Creates a new organization plan
|
||||
* Requires authentication
|
||||
*/
|
||||
.post('/plan', authMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => {
|
||||
try {
|
||||
const data = c.req.valid('json');
|
||||
const plan = await createOrgPlan(data);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: plan
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create organization plan');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* PUT /organization/plan
|
||||
* Updates an organization plan by subscription ID
|
||||
* Requires authentication
|
||||
*/
|
||||
.put('/plan', authMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => {
|
||||
try {
|
||||
const { subscriptionId, payload } = c.req.valid('json');
|
||||
const plan = await updateOrgPlan(subscriptionId, payload);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: plan
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update organization plan');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/plan/cancel
|
||||
* Cancels an organization plan by subscription ID
|
||||
* Requires authentication
|
||||
*/
|
||||
.post('/plan/cancel', authMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => {
|
||||
try {
|
||||
const { subscriptionId, payload } = c.req.valid('json');
|
||||
const plan = await cancelOrgPlan(subscriptionId, payload);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: plan
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to cancel organization plan');
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { app } from './app';
|
||||
import { hc } from 'hono/client';
|
||||
import type { InferRequestType, InferResponseType } from 'hono/client';
|
||||
|
||||
export type { InferRequestType, InferResponseType } from 'hono/client';
|
||||
export type Client = ReturnType<typeof hc<typeof app>>;
|
||||
|
||||
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args);
|
||||
|
||||
export type { InferRequestType, InferResponseType };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
|
||||
|
||||
import type { TProfile } from '@cio/db/types';
|
||||
import { getOrganizationByProfileId } from '@cio/db/queries/organization';
|
||||
|
||||
/**
|
||||
* Fetches account data including profile and organizations for a user
|
||||
* @param userId - The user ID to fetch account data for
|
||||
* @returns Account data with profile and organizations
|
||||
*/
|
||||
export async function getAccountData(userId: string) {
|
||||
const [profile, organizations] = await Promise.all([getProfileById(userId), getOrganizationByProfileId(userId)]);
|
||||
|
||||
if (!profile) {
|
||||
throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
organizations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates profile data for a user
|
||||
* @param userId - The user ID to update profile for
|
||||
* @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt)
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
data: Partial<Omit<TProfile, 'id' | 'email' | 'createdAt' | 'updatedAt'>>
|
||||
) {
|
||||
try {
|
||||
const updatedProfile = await updateProfile(userId, data);
|
||||
|
||||
if (!updatedProfile) {
|
||||
throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle unique constraint violations
|
||||
if (error instanceof Error && error.message.includes('profile_username_key')) {
|
||||
throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username');
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update profile',
|
||||
ErrorCodes.PROFILE_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './account';
|
||||
export * from './onboarding';
|
||||
export * from './mail';
|
||||
@@ -1,13 +1,9 @@
|
||||
import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail';
|
||||
import { nodemailerTransporter, zohoClient } from '@api/utils/email';
|
||||
import { extractNameAndEmail, nodemailerTransporter, withEmailTemplate, zohoClient } from '@api/utils/mail';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { TSendEmailValidation } from '@api/types/mail';
|
||||
import type { TEmailData } from '@cio/utils/validation/mail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '@api/config/env';
|
||||
|
||||
export const mailRouter = new Hono();
|
||||
|
||||
interface EmailResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -20,9 +16,7 @@ nodemailerTransporter().then((t: Transporter | null) => {
|
||||
transporter = t;
|
||||
});
|
||||
|
||||
export async function sendWithNodemailer(
|
||||
emailData: TSendEmailValidation[0]
|
||||
): Promise<EmailResponse> {
|
||||
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content, replyTo } = emailData;
|
||||
|
||||
if (!transporter) {
|
||||
@@ -54,7 +48,7 @@ export async function sendWithNodemailer(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
|
||||
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content } = emailData;
|
||||
|
||||
const fromData = extractNameAndEmail(from || '');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TProfile, TUser } from '@cio/db/types';
|
||||
import {
|
||||
checkSiteNameExists,
|
||||
createOrganization,
|
||||
createOrganizationMember,
|
||||
getOrganizationByProfileId
|
||||
} from '@cio/db/queries';
|
||||
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { sendEmail } from '@cio/email';
|
||||
|
||||
export async function createOrganizationWithOwner(
|
||||
profileId: string,
|
||||
input: {
|
||||
fullname?: string;
|
||||
orgName: string;
|
||||
siteName: string;
|
||||
}
|
||||
) {
|
||||
// Business Logic: Check sitename availability
|
||||
const exists = await checkSiteNameExists(input.siteName);
|
||||
if (exists) {
|
||||
console.error('Site name already exists:', input.siteName);
|
||||
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
|
||||
}
|
||||
|
||||
// Business Logic: Create org and member in a transaction
|
||||
try {
|
||||
const result = await db.transaction(async () => {
|
||||
const organization = await createOrganization({
|
||||
name: input.orgName,
|
||||
siteName: input.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) {
|
||||
console.error('Error creating organization:', error);
|
||||
// Handle database constraint violations
|
||||
if (error.code === '23505') {
|
||||
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
|
||||
}
|
||||
throw new AppError(error, ErrorCodes.ORG_CREATE_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserOnboarding(userId: string, data: Partial<TProfile>) {
|
||||
try {
|
||||
const updatedProfile = await updateProfile(userId, data);
|
||||
|
||||
if (!updatedProfile) {
|
||||
throw new AppError('Failed to update profile - profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(error, ErrorCodes.PROFILE_UPDATE_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeOnboarding(user: Partial<TUser>) {
|
||||
try {
|
||||
const profile = await getProfileById(user.id);
|
||||
if (!profile) {
|
||||
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await sendEmail('welcome', {
|
||||
to: user.email,
|
||||
fields: {
|
||||
name: profile.fullname
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(error, ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types';
|
||||
import {
|
||||
cancelOrganizationPlan,
|
||||
createOrganizationPlan,
|
||||
getOrganizationAudience,
|
||||
getOrganizationTeam,
|
||||
getOrganizations,
|
||||
updateOrganizationPlan
|
||||
} from '@cio/db/queries/organization';
|
||||
|
||||
import { getCoursesBySiteName } from '@cio/db/queries/course';
|
||||
|
||||
/**
|
||||
* Gets organizations with optional filters
|
||||
* @param filters - Filter options (siteName, customDomain, isCustomDomainVerified)
|
||||
* @returns Array of organizations with plans
|
||||
*/
|
||||
export async function getOrganizationsWithFilters(filters?: {
|
||||
siteName?: string;
|
||||
customDomain?: string;
|
||||
isCustomDomainVerified?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const organizations = await getOrganizations(filters);
|
||||
return organizations;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch organizations',
|
||||
ErrorCodes.ORGANIZATION_NOT_FOUND,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organization team members (non-students)
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of team members
|
||||
*/
|
||||
export async function getOrgTeam(orgId: string) {
|
||||
try {
|
||||
const team = await getOrganizationTeam(orgId);
|
||||
return team;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch organization team',
|
||||
ErrorCodes.ORG_TEAM_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organization audience (students)
|
||||
* @param orgId - The organization ID
|
||||
* @returns Array of student profiles
|
||||
*/
|
||||
export async function getOrgAudience(orgId: string) {
|
||||
try {
|
||||
const audience = await getOrganizationAudience(orgId);
|
||||
return audience;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch organization audience',
|
||||
ErrorCodes.ORG_AUDIENCE_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets courses by organization siteName
|
||||
* @param siteName - The organization site name
|
||||
* @returns Array of courses
|
||||
*/
|
||||
export async function getCoursesByOrgSiteName(siteName: string) {
|
||||
try {
|
||||
const courses = await getCoursesBySiteName(siteName);
|
||||
return courses;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch courses',
|
||||
ErrorCodes.COURSES_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new organization plan
|
||||
* @param data - Organization plan creation data
|
||||
* @returns Created organization plan
|
||||
*/
|
||||
export async function createOrgPlan(data: TNewOrganizationPlan) {
|
||||
try {
|
||||
const plan = await createOrganizationPlan({
|
||||
orgId: data.orgId,
|
||||
planName: data.planName,
|
||||
subscriptionId: data.subscriptionId,
|
||||
triggeredBy: data.triggeredBy,
|
||||
payload: data.payload,
|
||||
isActive: true,
|
||||
provider: 'polar'
|
||||
});
|
||||
return plan;
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create organization plan',
|
||||
ErrorCodes.ORG_PLAN_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an organization plan
|
||||
* @param subscriptionId - Subscription ID
|
||||
* @param payload - Payload data to update
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
export async function updateOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
|
||||
try {
|
||||
const plan = await updateOrganizationPlan(subscriptionId, payload);
|
||||
if (!plan) {
|
||||
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
|
||||
}
|
||||
return plan;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update organization plan',
|
||||
ErrorCodes.ORG_PLAN_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an organization plan
|
||||
* @param subscriptionId - Subscription ID
|
||||
* @param payload - Payload data to update
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) {
|
||||
try {
|
||||
const plan = await cancelOrganizationPlan(subscriptionId, payload);
|
||||
if (!plan) {
|
||||
throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404);
|
||||
}
|
||||
return plan;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to cancel organization plan',
|
||||
ErrorCodes.ORG_PLAN_CANCEL_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { auth } from '@cio/db/auth';
|
||||
|
||||
export type AuthSession = {
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
session: typeof auth.$Infer.Session.session | null;
|
||||
};
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '@api/constants/upload';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export * from './lesson';
|
||||
|
||||
export const ZCourseDownloadContent = z.object({
|
||||
courseTitle: z.string(),
|
||||
orgName: z.string(),
|
||||
orgTheme: z.string(),
|
||||
lessons: z.array(
|
||||
z.object({
|
||||
lessonTitle: z.string(),
|
||||
lessonNumber: z.string(),
|
||||
lessonNote: z.string(),
|
||||
slideUrl: z.string().optional(),
|
||||
video: z.array(z.string()).optional()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
|
||||
|
||||
export const ZCoursePresignUrlUpload = z.object({
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
|
||||
error: () => ({
|
||||
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
|
||||
|
||||
export const ZCourseDocumentPresignUrlUpload = z.object({
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
|
||||
error: () => ({
|
||||
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
|
||||
|
||||
export const ZCourseDownloadPresignedUrl = z.object({
|
||||
keys: z.array(z.string()).min(1, 'File names array is required')
|
||||
});
|
||||
|
||||
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
|
||||
|
||||
export const ZCourseClone = z.object({
|
||||
id: z.string().min(1, 'Course ID is required'),
|
||||
title: z.string().min(1, 'Course title is required'),
|
||||
description: z.string().optional(),
|
||||
slug: z.string().min(1, 'Course slug is required'),
|
||||
organizationId: z.string().min(1, 'Organization ID is required')
|
||||
});
|
||||
|
||||
export type TCourseClone = z.infer<typeof ZCourseClone>;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLessonDownloadContent = z.object({
|
||||
title: z.string(),
|
||||
number: z.string(),
|
||||
orgName: z.string(),
|
||||
note: z.string(),
|
||||
slideUrl: z.string().optional(),
|
||||
video: z.array(z.string()).optional(),
|
||||
courseTitle: z.string()
|
||||
});
|
||||
|
||||
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
|
||||
|
||||
export const ZCertificateDownload = z.object({
|
||||
theme: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
courseDescription: z.string().min(1),
|
||||
orgName: z.string().min(1),
|
||||
orgLogoUrl: z.string().url().optional(),
|
||||
facilitator: z.string().optional()
|
||||
});
|
||||
|
||||
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
|
||||
@@ -1,5 +1,2 @@
|
||||
// Re-export all types
|
||||
export * from './course';
|
||||
export * from './database';
|
||||
export * from './mail';
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSendEmailValidation = z.array(
|
||||
z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().email(),
|
||||
subject: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
replyTo: z.string().email().optional()
|
||||
})
|
||||
);
|
||||
|
||||
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
|
||||
@@ -1,16 +1,6 @@
|
||||
import type { TCertificateDownload } from '@api/types/course/lesson';
|
||||
import type { TCertificateDownload } from '@cio/utils/validation/course';
|
||||
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
|
||||
|
||||
export interface CertificateData {
|
||||
theme: string;
|
||||
studentName: string;
|
||||
courseName: string;
|
||||
courseDescription: string;
|
||||
orgLogoUrl: string;
|
||||
orgName: string;
|
||||
facilitator?: string;
|
||||
}
|
||||
|
||||
export interface CertificateGenerationResult {
|
||||
success: boolean;
|
||||
buffer?: Buffer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TCourseDownloadContent } from '@api/types/course';
|
||||
import type { TCourseDownloadContent } from '@cio/utils/validation/course';
|
||||
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
|
||||
import { marked } from 'marked';
|
||||
|
||||
@@ -138,12 +138,7 @@ function getLessonBody(
|
||||
`;
|
||||
}
|
||||
|
||||
function getCourseBody({
|
||||
courseTitle,
|
||||
orgName,
|
||||
orgTheme,
|
||||
lessons
|
||||
}: TCourseDownloadContent): string {
|
||||
function getCourseBody({ courseTitle, orgName, orgTheme, lessons }: TCourseDownloadContent): string {
|
||||
return `
|
||||
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
|
||||
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
|
||||
@@ -155,11 +150,7 @@ function getCourseBody({
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string,
|
||||
orgTheme: string
|
||||
): Promise<Buffer> {
|
||||
async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string, orgTheme: string): Promise<Buffer> {
|
||||
return await getCloudflarePdfBuffer(htmlContent);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { SendMailClient } from 'zeptomail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '@api/config/env';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export const zohoClient = new SendMailClient({
|
||||
url: 'api.zeptomail.eu/',
|
||||
token: env.ZOHO_TOKEN!
|
||||
});
|
||||
|
||||
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
|
||||
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
|
||||
console.error('SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT || '465', 10),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
console.error('Transporter error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
/**
|
||||
* Standard application error with code for easy identification
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
error: string | Error,
|
||||
public code: string,
|
||||
public statusCode: ContentfulStatusCode = 500,
|
||||
public field?: string
|
||||
) {
|
||||
super(error instanceof Error ? error.message : error);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response format
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
code: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
// Error codes constants for consistency
|
||||
export const ErrorCodes = {
|
||||
// Organization errors
|
||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
|
||||
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
|
||||
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
|
||||
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
|
||||
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
|
||||
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
|
||||
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
|
||||
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
|
||||
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
|
||||
|
||||
// Profile errors
|
||||
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
|
||||
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
|
||||
|
||||
// Account errors
|
||||
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
|
||||
|
||||
// Auth errors
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
|
||||
// Generic
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR'
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
|
||||
/**
|
||||
* Handles errors consistently across all routes
|
||||
* @param c - Hono context
|
||||
* @param error - The error to handle
|
||||
* @param fallbackMessage - Default error message if error is not an AppError
|
||||
* @param fallbackCode - Default error code if error is not an AppError
|
||||
* @returns JSON error response with appropriate status code
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* const data = await someService();
|
||||
* return c.json({ success: true, data });
|
||||
* } catch (error) {
|
||||
* return handleError(c, error, 'Failed to process request');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const handleError = (
|
||||
c: Context,
|
||||
error: unknown,
|
||||
fallbackMessage: string = 'An unexpected error occurred',
|
||||
fallbackCode: string = ErrorCodes.INTERNAL_ERROR
|
||||
) => {
|
||||
console.error('Error in route:', error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return c.json<ErrorResponse>(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
field: error.field
|
||||
},
|
||||
error.statusCode
|
||||
);
|
||||
}
|
||||
|
||||
return c.json<ErrorResponse>(
|
||||
{
|
||||
success: false,
|
||||
error: fallbackMessage,
|
||||
code: fallbackCode
|
||||
},
|
||||
500
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Hono as HonoBase } from 'hono';
|
||||
import { AuthSession } from '@api/types/auth';
|
||||
|
||||
export const Hono = HonoBase<AuthSession>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TLessonDownloadContent } from '@api/types/course/lesson';
|
||||
import type { TLessonDownloadContent } from '@cio/utils/validation/course';
|
||||
import { marked } from 'marked';
|
||||
|
||||
function getHtmlTemplate(body: string): string {
|
||||
@@ -74,15 +74,7 @@ function getHtmlTemplate(body: string): string {
|
||||
`;
|
||||
}
|
||||
|
||||
function getLessonBody({
|
||||
title,
|
||||
number,
|
||||
orgName,
|
||||
note,
|
||||
slideUrl,
|
||||
video,
|
||||
courseTitle
|
||||
}: TLessonDownloadContent): string {
|
||||
function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTitle }: TLessonDownloadContent): string {
|
||||
const noteHtml = marked.parse(note);
|
||||
const showExtraResources = slideUrl || (video && video.length > 0);
|
||||
return `
|
||||
@@ -146,10 +138,7 @@ function getLessonBody({
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateSinglePdfFromHtml(
|
||||
htmlContent: string,
|
||||
courseTitle: string
|
||||
): Promise<Buffer> {
|
||||
async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string): Promise<Buffer> {
|
||||
throw 'Download disabled';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
import { SendMailClient } from 'zeptomail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '@api/config/env';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export const zohoClient = new SendMailClient({
|
||||
url: 'api.zeptomail.eu/',
|
||||
token: env.ZOHO_TOKEN!
|
||||
});
|
||||
|
||||
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
|
||||
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
|
||||
console.error('SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT || '465', 10),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
console.error('Transporter error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface FromData {
|
||||
name: string;
|
||||
email: string;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { Scalar } from '@scalar/hono-api-reference';
|
||||
import { env } from '@api/config/env';
|
||||
import { AuthSession } from '@api/types/auth';
|
||||
|
||||
export function configureOpenAPI(app: Hono) {
|
||||
export function configureOpenAPI(app: Hono<AuthSession>) {
|
||||
if (env.OPENAPI_URL) {
|
||||
app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' }));
|
||||
}
|
||||
|
||||
+21
-7
@@ -1,25 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": "./",
|
||||
"baseUrl": ".",
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@api/*": ["src/*"]
|
||||
},
|
||||
"rootDir": "./src",
|
||||
"resolveJsonModule": false,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"target": "ESNext",
|
||||
"typeRoots": ["../../node_modules/@types", "./node_modules/@types", "./src/types"],
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@api/*": ["src/*"],
|
||||
"@db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
|
||||
"@db/*": ["./node_modules/@cio/db/dist/*"],
|
||||
"@cio/db/*": ["./node_modules/@cio/db/dist/*"],
|
||||
"@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
|
||||
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
|
||||
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
|
||||
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
|
||||
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
|
||||
"references": [
|
||||
{ "path": "../../packages/db" },
|
||||
{ "path": "../../packages/utils" },
|
||||
{ "path": "../../packages/email" }
|
||||
],
|
||||
"tsc-alias": {
|
||||
"resolveFullPaths": true,
|
||||
"verbose": false
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^0.21.16",
|
||||
"clsx": "^2.1.1",
|
||||
"mode-watcher": "^0.5.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
@@ -40,6 +39,7 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"lucide-svelte": "^0.468.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"posthog-js": "^1.180.1",
|
||||
"svelte-inview": "^4.0.4",
|
||||
"svelte-motion": "^0.12.2",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
|
||||
|
||||
const getClass = (active: boolean) => (active ? 'text-foreground' : 'text-foreground/60');
|
||||
|
||||
let activeClass = $derived({
|
||||
template: getClass($page.url.href.includes('#templates')),
|
||||
features: getClass($page.url.href.includes('#features'))
|
||||
template: getClass(page.url.href.includes('#templates')),
|
||||
features: getClass(page.url.href.includes('#features'))
|
||||
});
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur"
|
||||
class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
<p class="bg-blue-700 py-1.5 text-center text-sm font-medium text-white">
|
||||
brought to you by
|
||||
@@ -59,7 +59,7 @@
|
||||
href="/npm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-foreground/80 text-foreground/60 flex items-center gap-1 transition-colors"
|
||||
class="flex items-center gap-1 text-foreground/60 transition-colors hover:text-foreground/80"
|
||||
>
|
||||
NPM <ArrowUpRight class="h-4 w-4" />
|
||||
</a>{' '}
|
||||
@@ -80,7 +80,7 @@
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div
|
||||
class="focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground inline-flex h-8 w-8 items-center justify-center whitespace-nowrap rounded-md px-0 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
class="inline-flex h-8 w-8 items-center justify-center whitespace-nowrap rounded-md px-0 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<svg viewBox="0 0 438.549 438.549" class="h-4 w-4">
|
||||
<path
|
||||
@@ -93,7 +93,7 @@
|
||||
</a>{' '}
|
||||
<a href="https://twitter.com/classroomio" target="_blank" rel="noreferrer">
|
||||
<div
|
||||
class="focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground inline-flex h-8 w-8 items-center justify-center whitespace-nowrap rounded-md px-0 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
class="inline-flex h-8 w-8 items-center justify-center whitespace-nowrap rounded-md px-0 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
height="23"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", ".svelte-kit", "dist", "build", "**/*.spec.ts", "**/*.test.ts"]
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Auth flow
|
||||
|
||||
Documenting my thoughts on how authentication works in the app.
|
||||
|
||||
## Simplified breakdown
|
||||
|
||||
### dashboard (app.classroomio.com)
|
||||
|
||||
- sign up (email, password or google auth)
|
||||
- redirect to onboarding.
|
||||
- redirect to dashboard
|
||||
|
||||
### dashboard - invite (admin or teacher)
|
||||
|
||||
- insert email into organizationmember table
|
||||
- click invite link
|
||||
- sign up or login
|
||||
- update org with role (as role in invite)
|
||||
|
||||
### lms (school.classroomio.com)
|
||||
|
||||
- create email and password
|
||||
- join org as student (automatically).
|
||||
|
||||
### lms - invite
|
||||
|
||||
- click invite link
|
||||
- redirect to login and come back to invite page.
|
||||
- join org as student (automatically).
|
||||
|
||||
---
|
||||
|
||||
## Better Auth to Supabase TODO
|
||||
|
||||
### actions to trigger [in code]
|
||||
|
||||
- [ ] After sign up
|
||||
- [x] create a profile by new user.
|
||||
- [ ] if sign up was triggered from a subdomain (aka is lms), add profile to org with student role
|
||||
- [ ] send verification email
|
||||
|
||||
### migrate from supabase
|
||||
|
||||
- [x] after login redirect to next page
|
||||
- [x] after signup redirect to next page (onboarding | lms | )
|
||||
- [x] implement logout
|
||||
- [x] forgot password flow
|
||||
- [x] complete onboarding flow
|
||||
- [x] change email flow.
|
||||
- [x] google auth flow (local and on test environment)
|
||||
- [ ] support google auth for teacher invite
|
||||
- [ ] support google auth for student invite
|
||||
|
||||
### others
|
||||
|
||||
- [x] block org routes when isOrgSite === true.
|
||||
- [ ] routes belonging to orgsite should proxy to courseapp (site-theme)
|
||||
|
||||
---
|
||||
|
||||
## invite v2.0
|
||||
|
||||
should an admin invite student into an org alone?
|
||||
|
||||
- yes
|
||||
- and also give them access to all resources in the org.
|
||||
|
||||
student
|
||||
|
||||
- send you invite to join course
|
||||
- you click link in email
|
||||
- you are redirected to accept page
|
||||
- you enter your name and password with email prefilled
|
||||
- you click join.
|
||||
|
||||
how do teachers send invite?
|
||||
|
||||
- email input field
|
||||
- teacher can send to multiple emails
|
||||
- teacher can deactivate access.
|
||||
- teacher can see who has accepted the invite and when.
|
||||
@@ -21,8 +21,9 @@
|
||||
"dependencies": {
|
||||
"@carbon/charts-svelte": "^1.13.6",
|
||||
"@cio/api": "workspace:*",
|
||||
"@cio/db": "workspace:*",
|
||||
"@cio/ui": "workspace:*",
|
||||
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||
"@cio/utils": "workspace:*",
|
||||
"@lucide/svelte": "^0.545.0",
|
||||
"@polar-sh/sveltekit": "^0.2.1",
|
||||
"@supabase/supabase-js": "^2.31.0",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@types/pluralize": "0.0.30",
|
||||
"ai": "^2.1.31",
|
||||
"axios": "^1.8.4",
|
||||
"better-auth": "^1.3.34",
|
||||
"bits-ui": "^2.14.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"bufferutil": "^4.0.9",
|
||||
@@ -59,7 +61,6 @@
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "^1.180.1",
|
||||
"@cio/utils": "workspace:*",
|
||||
"sirv": "^2.0.3",
|
||||
"stripe": "^14.22.0",
|
||||
"svelte-awesome-color-picker": "^3.1.4",
|
||||
@@ -69,7 +70,7 @@
|
||||
"unsplash-js": "^7.0.18",
|
||||
"utf-8-validate": "^6.0.5",
|
||||
"wait-on": "^7.0.1",
|
||||
"zod": "^3.21.4"
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.9",
|
||||
@@ -78,13 +79,14 @@
|
||||
"@babel/preset-env": "^7.22.9",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@babel/runtime": "^7.22.6",
|
||||
"@cio/tsconfig": "workspace:*",
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/svelte": "^4.0.5",
|
||||
"@types/jest": "^29.5.10",
|
||||
@@ -112,7 +114,6 @@
|
||||
"tailwindcss": "^4.1.14",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"@cio/tsconfig": "workspace:*",
|
||||
"tslib": "^2.6.1",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
};
|
||||
@@ -10,21 +10,6 @@ html {
|
||||
--scrollbarBG: #fff;
|
||||
--thumbBG: #ccc;
|
||||
scroll-behavior: smooth;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
Segoe UI Symbol,
|
||||
'Noto Color Emoji' !important;
|
||||
}
|
||||
|
||||
#calendar .grid {
|
||||
@@ -146,7 +131,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(-15px, 0, 0);
|
||||
@@ -828,7 +812,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes shakeAnnouncement {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -936,7 +919,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes gradeGlow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
@@ -950,7 +932,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes peopleWave {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
@@ -997,7 +978,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes screenGlow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
@@ -1171,4 +1151,4 @@ svg.lucide-icon:not(.custom) {
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import type { TUser, TSession, TProfile, TOrganization, TOrganizationmember, TOrganizationPlan } from '@cio/db/types';
|
||||
|
||||
type AccountOrganization = TOrganization & {
|
||||
member: TOrganizationmember | null;
|
||||
plan:
|
||||
| (Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & { customerId: string })
|
||||
| null;
|
||||
};
|
||||
|
||||
// src/app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: TUser | null;
|
||||
session: TSession | null;
|
||||
profile: TProfile | null;
|
||||
organizations: AccountOrganization[];
|
||||
fromSessions?: boolean;
|
||||
// getAccount: () =>
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,49 +1,52 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { validateUser } from '$lib/utils/services/middlewares';
|
||||
import { getSessionData } from '$lib/utils/services/auth/session';
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
'/api/completion',
|
||||
'student_prove_payment',
|
||||
'teacher_student_buycourse',
|
||||
'/api/polar',
|
||||
'/api/lmz',
|
||||
'/api/verify'
|
||||
];
|
||||
export const handle: Handle = async (args) => {
|
||||
const { event } = args;
|
||||
const sessionData = await getSessionData(event.cookies);
|
||||
|
||||
function isPublicRoute(pathname: string) {
|
||||
return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
|
||||
}
|
||||
if (sessionData) {
|
||||
event.locals = sessionData;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
// Handle API routes
|
||||
if (event.url.pathname.includes('/api')) {
|
||||
return handleAPIRoutes(args);
|
||||
}
|
||||
|
||||
// Handle other routes (ui pages, etc.)
|
||||
return handlePagesRoutes(args);
|
||||
};
|
||||
|
||||
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
// Only validate API routes
|
||||
if (!pathname.includes('/api')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Skip public routes
|
||||
if (isPublicRoute(pathname)) {
|
||||
return response;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const accessToken = event.request.headers.get('Authorization')!;
|
||||
if (!event.locals.user) {
|
||||
const shouldAddRedirectParam = !pathname.includes(ROUTE.LOGOUT);
|
||||
const redirectPath = shouldAddRedirectParam ? `${ROUTE.LOGIN}?redirect=${pathname}` : ROUTE.LOGIN;
|
||||
|
||||
try {
|
||||
const user = await validateUser(accessToken);
|
||||
|
||||
response.headers.set('user_id', `${user.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Unauthenticated user') {
|
||||
return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
}
|
||||
return redirect(303, redirectPath);
|
||||
}
|
||||
|
||||
return response;
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleAPIRoutes: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
if (isPublicApiRoute(pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
if (!event.locals.user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Activity {
|
||||
icon: any;
|
||||
icon: Component;
|
||||
title: string;
|
||||
percentage: number | string;
|
||||
description: string;
|
||||
|
||||
@@ -7,24 +7,22 @@
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
|
||||
interface Props {
|
||||
supabase: SupabaseClient;
|
||||
handleSubmit?: any;
|
||||
isLogin?: boolean;
|
||||
showOnlyContent?: boolean;
|
||||
isLoading?: boolean;
|
||||
showLogo?: boolean;
|
||||
formRef: any;
|
||||
formRef?: any;
|
||||
hideGoogleAuth?: boolean;
|
||||
redirectPathname?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
supabase,
|
||||
handleSubmit = () => {},
|
||||
isLogin = true,
|
||||
showOnlyContent = false,
|
||||
@@ -46,22 +44,19 @@
|
||||
// const redirectTo = `https://app.classroomio.com?forwardTo=${
|
||||
// window.location.origin + params.get('redirect')
|
||||
// }`;
|
||||
const pathname = redirectPathname || params.get('redirect');
|
||||
const pathname = redirectPathname || params.get('redirect') || '';
|
||||
const redirectTo = `${window.location.origin + pathname}`;
|
||||
|
||||
console.log({ redirectTo });
|
||||
|
||||
try {
|
||||
console.log('signInWithGoogle');
|
||||
const { error, data } = await supabase.auth.signInWithOAuth({
|
||||
const data = await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo
|
||||
}
|
||||
callbackURL: redirectTo
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
} catch (error) {
|
||||
console.log('catch error', error);
|
||||
}
|
||||
@@ -74,7 +69,7 @@
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<div class="flex w-full flex-col items-center justify-center pt-2">
|
||||
<Avatar
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : '/logo-192.png'}
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
shape="rounded-md"
|
||||
width="w-10"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
interface Props {
|
||||
onClick?: IconButtonProps['onClick'];
|
||||
contained?: boolean;
|
||||
isClosing?: boolean;
|
||||
color?: string;
|
||||
size?: IconButtonProps['size'];
|
||||
toolTipProps?: IconButtonProps['toolTipProps'];
|
||||
@@ -13,11 +15,16 @@
|
||||
let {
|
||||
onClick = () => {},
|
||||
contained = false,
|
||||
isClosing = false,
|
||||
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
|
||||
size = 'small'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<IconButton {onClick} {color} {contained} {size}>
|
||||
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
|
||||
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
|
||||
{#if isClosing}
|
||||
<Circle size="20" color="#1d4ee2" unit="px" duration="1s" />
|
||||
{:else}
|
||||
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
// import * as Tabs from '@cio/ui/base/tabs';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
|
||||
@@ -84,7 +83,7 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const tabParam = $page.url.searchParams.get('tab');
|
||||
const tabParam = page.url.searchParams.get('tab');
|
||||
if (tabParam) {
|
||||
selectedTab = tabParam;
|
||||
}
|
||||
@@ -92,7 +91,7 @@
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
goto($page.url.pathname + '?tab=' + selectedTab);
|
||||
goto(page.url.pathname + '?tab=' + selectedTab);
|
||||
});
|
||||
});
|
||||
// $effect(() => {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import Confetti from '$lib/components/Confetti/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -161,7 +160,7 @@
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
|
||||
+3
-3
@@ -12,14 +12,14 @@
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
interface LessonHistory {
|
||||
new_content: string;
|
||||
old_content: string;
|
||||
timestamp: Date;
|
||||
locale: LOCALE;
|
||||
locale: TLocale;
|
||||
lesson_id: string;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
new_content: '',
|
||||
old_content: '',
|
||||
timestamp: new Date(),
|
||||
locale: LOCALE.EN,
|
||||
locale: 'en',
|
||||
lesson_id: ''
|
||||
};
|
||||
let selectedVersionIndex = $state(0);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { LessonPage, LOCALE } from '$lib/utils/types';
|
||||
import type { LessonPage } from '$lib/utils/types';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import {
|
||||
deleteLessonVideo,
|
||||
@@ -50,6 +50,7 @@
|
||||
import ComponentDocument from './components/ComponentDocument.svelte';
|
||||
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
|
||||
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
interface Props {
|
||||
mode?: any;
|
||||
@@ -72,7 +73,6 @@
|
||||
let openPopover = $state<boolean>(false);
|
||||
let aiButtonClass =
|
||||
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
|
||||
let player = $state<HTMLVideoElement>();
|
||||
let localeExists: Record<string, boolean> = {};
|
||||
// let prevContent = '';
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
@@ -194,7 +194,7 @@
|
||||
return lessonRes;
|
||||
}
|
||||
|
||||
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<LOCALE, string>) {
|
||||
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<TLocale, string>) {
|
||||
const { slide_url, videos, note, documents } = materials;
|
||||
|
||||
return (
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
function autoSave(
|
||||
updatedMaterials: LessonPage['materials'],
|
||||
_translation: Record<LOCALE, string>,
|
||||
_translation: Record<TLocale, string>,
|
||||
_isLoading?: boolean,
|
||||
_lessonId?: string
|
||||
) {
|
||||
@@ -343,6 +343,7 @@
|
||||
// updateNoteByCompletion($completion);
|
||||
// });
|
||||
|
||||
let player = $state<HTMLVideoElement | null>(null);
|
||||
$effect(() => {
|
||||
initPlyr(player, $lesson.materials.videos);
|
||||
});
|
||||
@@ -531,8 +532,9 @@
|
||||
<Component {lessonId} />
|
||||
{/each}
|
||||
|
||||
{#if $currentOrg.customization.apps.comments}
|
||||
{#if $currentOrg.customization?.apps?.comments}
|
||||
<hr class="my-5" />
|
||||
{$currentOrg.customization}
|
||||
<Comments {lessonId} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Course, Lesson, LessonComment, LessonCompletion, LessonPage, LessonSection } from '$lib/utils/types';
|
||||
import type { Updater, Writable } from 'svelte/store';
|
||||
import {
|
||||
createLesson,
|
||||
createLessonSection,
|
||||
@@ -9,8 +8,9 @@ import {
|
||||
updateLessonSection
|
||||
} from '$lib/utils/services/courses';
|
||||
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { lessonValidation } from '$lib/utils/functions/validator';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { writable } from 'svelte/store';
|
||||
@@ -40,7 +40,7 @@ export const lessonCommentsChannel: Writable<RealtimeChannel> = writable();
|
||||
|
||||
export const lesson = writable<LessonPage>({
|
||||
id: null,
|
||||
locale: LOCALE.EN,
|
||||
locale: 'en',
|
||||
title: '',
|
||||
totalExercises: 0,
|
||||
totalComments: 0,
|
||||
@@ -57,7 +57,7 @@ export const lesson = writable<LessonPage>({
|
||||
});
|
||||
|
||||
export const lessonByTranslation = writable<{
|
||||
[key: string]: Record<LOCALE, string>;
|
||||
[key: string]: Record<TLocale, string>;
|
||||
}>({});
|
||||
|
||||
export const lessonComments = writable<LessonComment[]>([]);
|
||||
@@ -72,8 +72,8 @@ export function setLesson({
|
||||
totalComments
|
||||
}: {
|
||||
id: string;
|
||||
lessonData: Lesson & { lesson_language: { locale: LOCALE; content: string }[] };
|
||||
locale: LOCALE;
|
||||
lessonData: Lesson & { lesson_language: { locale: TLocale; content: string }[] };
|
||||
locale: TLocale;
|
||||
totalExercises: number;
|
||||
totalComments: number;
|
||||
}) {
|
||||
@@ -111,7 +111,7 @@ export function setLesson({
|
||||
acc[cur.locale] = cur.content;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<LOCALE, string>
|
||||
{} as Record<TLocale, string>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
|
||||
import { NAV_IDS } from './constants';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { sideBar } from '$lib/components/Org/store';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { handleAddLessonWidget } from '../Lesson/store';
|
||||
import { getIsLessonComplete } from '../Lesson/functions';
|
||||
@@ -50,7 +48,7 @@
|
||||
url: getNavItemRoute($course.id),
|
||||
isActive: (path || page.url.pathname) === getNavItemRoute($course.id),
|
||||
show() {
|
||||
return isStudent ? $currentOrg.customization.course.newsfeed : true;
|
||||
return isStudent ? $currentOrg.customization?.['course']?.['newsfeed'] : true;
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.NEWS_FEED)
|
||||
},
|
||||
@@ -121,7 +119,7 @@
|
||||
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'marks'),
|
||||
show() {
|
||||
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
|
||||
return isStudent ? $currentOrg.customization.course.grading : true;
|
||||
return isStudent ? ($currentOrg.customization?.['course']?.['grading'] ?? false) : true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -186,8 +184,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSidebarOnMobile = () => $isMobile && ($sideBar.hidden = !$sideBar.hidden);
|
||||
|
||||
function getNavIcon(id: string) {
|
||||
if (!id) return null;
|
||||
|
||||
@@ -217,10 +213,12 @@
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleSidebarOnMobile() {}
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider class="flex w-fit items-start gap-2">
|
||||
<Sidebar.Root collapsible="icon" class="inset-y-12 h-[calc(100vh-48px)] {$sideBar.hidden ? 'hidden' : ''}">
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Course Navigation</Sidebar.GroupLabel>
|
||||
@@ -232,7 +230,7 @@
|
||||
{#if item.isLesson && item.items}
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} onclick={toggleSidebarOnMobile}>
|
||||
<a href={item.url}>
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
tooltipContent={item.title}
|
||||
@@ -376,7 +374,7 @@
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent={item.title}
|
||||
class="flex w-full cursor-pointer items-center gap-4 px-1.5 py-2 {item.isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
? 'ui:bg-accent ui:text-accent-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{@const Icon = item.icon}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
|
||||
import { qrInviteNodeStore } from './store';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getOrgTeam } from '$lib/utils/services/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
@@ -11,13 +11,11 @@
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Row from '$lib/components/Org/Settings/Layout/Row.svelte';
|
||||
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
|
||||
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
|
||||
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Column from '$lib/components/Org/Settings/Layout/Column.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
|
||||
import { settings } from './store';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/type
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import { lessonSections, lessons } from './components/Lesson/store/lessons';
|
||||
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { fetchCourse } from '$lib/utils/services/courses';
|
||||
|
||||
export const defaultCourse: Course = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
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>
|
||||
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
|
||||
+2
-6
@@ -2,17 +2,13 @@
|
||||
import get from 'lodash/get';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import {
|
||||
isCourseFree,
|
||||
getStudentInviteLink,
|
||||
calcCourseDiscount
|
||||
} from '$lib/utils/functions/course';
|
||||
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { goto } from '$app/navigation';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import PaymentModal from './PaymentModal.svelte';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
|
||||
@@ -2,27 +2,22 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="root relative mb-4 mr-4 w-72 rounded-md border bg-gray-100 transition ease-in-out dark:bg-black">
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<div class="p-3 pb-0">
|
||||
<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">
|
||||
<Skeleton class="h-[45%] w-full" />
|
||||
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
|
||||
<!-- Title -->
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
|
||||
<!-- Description -->
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
<Skeleton class="mt-4 h-4 w-full" />
|
||||
<Skeleton class="mb-4 h-4 w-full" />
|
||||
<div class="mb-6 mt-4 flex flex-col gap-2">
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
<Skeleton class=" h-4 w-full" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex justify-between">
|
||||
<div class="flex justify-between">
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 370px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import { validateForm } from '$lib/components/Courses/functions';
|
||||
import { courses, createCourseModal } from '$lib/components/Courses/store';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
@@ -11,7 +11,7 @@
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
|
||||
@@ -151,7 +151,7 @@
|
||||
{$t('courses.new_course_modal.type_selector_title')}
|
||||
</h2>
|
||||
|
||||
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row!">
|
||||
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="w-11/12 rounded-md border-2 p-5 md:h-60 md:w-[261px] dark:bg-neutral-700 {option.type === type
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { sideBar } from '$lib/components/Org/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import NavUser from '../Sidebar/components/nav-user.svelte';
|
||||
import { SidebarFooterMenu } from '$lib/features/ui/sidebar/footer';
|
||||
|
||||
interface SideLinks {
|
||||
name: string;
|
||||
@@ -25,7 +24,7 @@
|
||||
const user = $derived.by(() => ({
|
||||
name: $profile.fullname,
|
||||
email: $profile.email,
|
||||
avatar: $profile.avatar_url
|
||||
avatar: $profile.avatarUrl
|
||||
}));
|
||||
|
||||
function isActive(pagePath: string, itemPath: string) {
|
||||
@@ -74,14 +73,10 @@
|
||||
}
|
||||
].filter((link) => (link.show ? link.show() : true))
|
||||
);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
$sideBar.hidden = !$sideBar.hidden;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider class="w-fit">
|
||||
<Sidebar.Root collapsible="icon" class="inset-y-12 h-[calc(100vh-48px)] {$sideBar.hidden ? 'hidden' : ''}">
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>LMS Navigation</Sidebar.GroupLabel>
|
||||
@@ -91,9 +86,8 @@
|
||||
<Sidebar.MenuButton tooltipContent={item.name}>
|
||||
<a
|
||||
href={item.link}
|
||||
onclick={toggleSidebar}
|
||||
class="flex w-full items-center gap-4 {isActive(page.url.pathname, item.link)
|
||||
? 'bg-accent text-accent-foreground rounded-md px-3 py-2'
|
||||
? 'ui:bg-accent ui:text-accent-foreground rounded-md px-3 py-2'
|
||||
: ''}"
|
||||
>
|
||||
<item.icon size={16} />
|
||||
@@ -108,7 +102,7 @@
|
||||
|
||||
{#if user}
|
||||
<Sidebar.Footer>
|
||||
<NavUser {user} />
|
||||
<SidebarFooterMenu />
|
||||
</Sidebar.Footer>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script>
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Row from '$lib/components/Org/Settings/Layout/Row.svelte';
|
||||
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
|
||||
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import Column from '$lib/components/Org/Settings/Layout/Column.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import LanguagePicker from '$lib/components/Org/Settings/LanguagePicker.svelte';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
containerClass?: string;
|
||||
size?: string;
|
||||
isCloseable?: boolean;
|
||||
isClosing?: boolean;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@
|
||||
containerClass = '',
|
||||
size = '',
|
||||
isCloseable = true,
|
||||
isClosing = false,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -40,7 +42,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-gray-100 bg-opacity-50"
|
||||
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
|
||||
onclick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -48,24 +50,24 @@
|
||||
<div
|
||||
class="{maxWidth || 'container'} {size === 'sm'
|
||||
? 'small'
|
||||
: ''} mx-auto bg-white dark:bg-neutral-800 {width} rounded-md pt-2 shadow-lg"
|
||||
: ''} mx-auto bg-white dark:bg-neutral-800 {width} max-h-4/5 overflow-y-auto rounded-md shadow-lg"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="{headerClass} flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 p-4 px-5 dark:border-neutral-600"
|
||||
class="{headerClass} sticky top-0 z-10 flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 bg-white p-4 px-5 dark:border-neutral-600 dark:bg-neutral-800"
|
||||
>
|
||||
<p class="text-md m-0 font-medium dark:text-white {labelClass}">
|
||||
{modalHeading}
|
||||
</p>
|
||||
{#if isCloseable}
|
||||
<div class="rounded-full">
|
||||
<CloseButton onClick={onClose} contained={true} />
|
||||
<CloseButton onClick={onClose} contained={true} {isClosing} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="body h-4/5 overflow-y-auto p-6 {containerClass}">
|
||||
<div class="body p-6 {containerClass} ">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Modal } from './index.svelte';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { stopPropagation } from '$lib/utils/functions/svelte';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { type TCustomLinks } from './types';
|
||||
import CustomLinks from './CustomLinks.svelte';
|
||||
@@ -55,7 +55,7 @@
|
||||
<CustomLinks {customLinks} isMobile={true} onMobileClick={closeMobileMenu} />
|
||||
|
||||
<!-- Login/Signup buttons in mobile menu -->
|
||||
{#if !$user.isLoggedIn && !$page.url.pathname?.includes('/404')}
|
||||
{#if !$user.isLoggedIn && !page.url.pathname?.includes('/404')}
|
||||
<li class="pt-4">
|
||||
<AuthButtons {disableSignup} {redirect} isMobile={true} onMobileClick={closeMobileMenu} />
|
||||
</li>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import MenuIcon from '@lucide/svelte/icons/menu';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
|
||||
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { sideBar } from '../Org/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -20,42 +16,15 @@
|
||||
|
||||
let { title = $bindable(''), navClass = '', isCoursePage = false }: Props = $props();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
$sideBar.hidden = !$sideBar.hidden;
|
||||
};
|
||||
|
||||
let coursesPath = $derived(
|
||||
$globalStore.isOrgSite ? '/lms/mylearning' : isCoursePage ? `${$currentOrgPath}/courses` : $currentOrgPath
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="{navClass} bg-primary-700 flex h-12 w-full p-1 transition delay-150 duration-300 ease-in-out md:px-6">
|
||||
<nav class="{navClass} bg-primary-700 flex h-12 w-full p-1 transition delay-150 duration-300 ease-in-out md:px-4">
|
||||
<ul class="flex w-full items-center gap-2">
|
||||
<div class="flex items-center text-white">
|
||||
<li class="md:hidden">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
toggleSidebar();
|
||||
}}
|
||||
>
|
||||
{#if $sideBar.hidden}
|
||||
<MenuIcon size={16} class="custom" />
|
||||
{:else}
|
||||
<XIcon size={16} class="custom" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</li>
|
||||
|
||||
{#if isCoursePage}
|
||||
<li class="hidden md:block">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
toggleSidebar();
|
||||
}}
|
||||
>
|
||||
<MenuIcon size={16} class="custom" />
|
||||
</IconButton>
|
||||
</li>
|
||||
<li class="hidden md:block">
|
||||
<IconButton onClick={() => goto(coursesPath)}>
|
||||
<ArrowLeftIcon size={16} class="custom" />
|
||||
@@ -77,16 +46,6 @@
|
||||
<li>
|
||||
<BellIcon class="custom text-white" size={16} />
|
||||
</li>
|
||||
<li>
|
||||
<!-- <IconButton size="small" onClick={toggleDarkMode}>
|
||||
{#if $globalStore.isDark}
|
||||
<SunIcon size={16} class="custom" />
|
||||
{:else}
|
||||
<MoonIcon class="custom text-white" size={16} />
|
||||
{/if}
|
||||
</IconButton> -->
|
||||
<ModeSwitcher />
|
||||
</li>
|
||||
|
||||
<li></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { isCoursePage } from '$lib/utils/functions/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -32,7 +32,7 @@
|
||||
let navClass = '';
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
let redirect = $derived(isCoursePage($page.url.pathname) ? `?redirect=${$page.url.pathname}` : '');
|
||||
let redirect = $derived(isCoursePage(page.url.pathname) ? `?redirect=${page.url.pathname}` : '');
|
||||
let showLinks = $derived(customLinks && customLinks.show && customLinks.links && customLinks.links.length > 0);
|
||||
|
||||
function toggleMobileMenu() {
|
||||
@@ -69,12 +69,12 @@
|
||||
{#if isOrgSite}
|
||||
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
|
||||
{/if}
|
||||
{:else if isOrgSite && !$page.url.pathname?.includes('/404')}
|
||||
{:else if isOrgSite && !page.url.pathname?.includes('/404')}
|
||||
<!-- Hide login/signup buttons on mobile when custom links exist -->
|
||||
<div class="hidden lg:block">
|
||||
<AuthButtons {disableSignup} {redirect} />
|
||||
</div>
|
||||
{:else if !isOrgSite && !$page.url.pathname?.includes('/404')}
|
||||
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
|
||||
<AuthButtons {disableSignup} {redirect} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { browser } from '$app/environment';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import MenuIcon from '@lucide/svelte/icons/menu';
|
||||
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { sideBar } from '$lib/components/Org/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { toggleBodyByMode } from '$lib/utils/functions/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -22,19 +13,6 @@
|
||||
|
||||
let { navClass = '' }: Props = $props();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
$sideBar.hidden = !$sideBar.hidden;
|
||||
};
|
||||
|
||||
function toggleDarkMode() {
|
||||
$globalStore.isDark = !$globalStore.isDark;
|
||||
|
||||
toggleBodyByMode($globalStore.isDark);
|
||||
if (browser) {
|
||||
localStorage.setItem('mode', $globalStore.isDark ? 'dark' : '');
|
||||
}
|
||||
}
|
||||
|
||||
function shortenName(name: string) {
|
||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
||||
}
|
||||
@@ -42,15 +20,6 @@
|
||||
|
||||
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
|
||||
<ul class="flex w-full items-center">
|
||||
<li class="md:hidden">
|
||||
<IconButton onClick={toggleSidebar}>
|
||||
{#if $sideBar.hidden}
|
||||
<MenuIcon size={16} />
|
||||
{:else}
|
||||
<XIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</li>
|
||||
<div class="">
|
||||
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
|
||||
{#if $currentOrg.avatar_url}
|
||||
@@ -76,15 +45,6 @@
|
||||
<li>
|
||||
<BellIcon class="text-white" size={16} />
|
||||
</li>
|
||||
<li>
|
||||
<IconButton size="small" onClick={toggleDarkMode}>
|
||||
{#if $globalStore.isDark}
|
||||
<SunIcon class="text-white" size={16} />
|
||||
{:else}
|
||||
<MoonIcon class="text-white" size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</li>
|
||||
<li></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Box className="w-screen">
|
||||
<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>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
|
||||
import NewsSection from './components/NewsSection.svelte';
|
||||
import LogoutButton from './components/LogoutButton.svelte';
|
||||
</script>
|
||||
|
||||
<div class="cursor-pointer space-y-2">
|
||||
{#if !$globalStore.isOrgSite}
|
||||
<NewsSection />
|
||||
{/if}
|
||||
|
||||
<LogoutButton />
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import posthog from 'posthog-js';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg, orgs, defaultCurrentOrgState } from '$lib/utils/store/org';
|
||||
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
|
||||
|
||||
async function logout() {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error('Error logging out: ', error);
|
||||
}
|
||||
|
||||
currentOrg.set(defaultCurrentOrgState);
|
||||
orgs.set([]);
|
||||
user.set(defaultUserState);
|
||||
profile.set(defaultProfileState);
|
||||
|
||||
capturePosthogEvent('user_logged_out');
|
||||
posthog.reset();
|
||||
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item>
|
||||
<button onclick={logout} class="w-full cursor-pointer">
|
||||
<span class="flex items-center gap-2">
|
||||
<LogOutIcon />
|
||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
|
||||
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
|
||||
<BellPlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href="https://classroomio.com/blog/launch-week" target="_blank" class="flex w-full items-center gap-2">
|
||||
<RocketIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href={$currentOrgPath} class="flex h-full w-full items-center justify-start space-x-2 text-start">
|
||||
<BadgeHelpIcon size={20} />
|
||||
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -24,11 +24,9 @@
|
||||
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
|
||||
|
||||
import PageLoader from './PageLoader.svelte';
|
||||
import Row from '../Settings/Layout/Row.svelte';
|
||||
import Grid from '../Settings/Layout/Grid.svelte';
|
||||
import { Row, Grid, Column } from '../Settings/Layout';
|
||||
import * as Accordion from '@cio/ui/base/accordion';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import Column from '../Settings/Layout/Column.svelte';
|
||||
import type { CurrentOrg } from '$lib/utils/types/org';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { getCourseBySiteName } from '$lib/utils/services/org';
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as Command from '@cio/ui/base/command';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
import Menu from '../FooterMenu/Menu.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center">
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-md p-1.5 hover:bg-gray-200 dark:hover:bg-neutral-800"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
<div class="flex w-full items-center justify-start space-x-2 text-start">
|
||||
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-[1.2rem]" height="h-[1.2rem]" />
|
||||
<p class="max-w-full truncate text-sm font-medium dark:text-white">
|
||||
{$profile.fullname}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronsUpDownIcon size={16} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content class="w-[200px] p-0" side="right" align="start">
|
||||
<Command.Root>
|
||||
<Command.List>
|
||||
<Menu />
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
@@ -4,9 +4,7 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
@@ -20,7 +18,7 @@
|
||||
try {
|
||||
if ($currentOrgPlan?.provider === 'polar') {
|
||||
const url = new URL('/api/polar/portal', window.location.origin);
|
||||
url.searchParams.set('customerId', $currentOrgPlan?.customerId);
|
||||
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
|
||||
|
||||
window.open(url.toString(), '_blank');
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
|
||||
@@ -18,15 +18,13 @@
|
||||
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
|
||||
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -158,7 +156,7 @@
|
||||
|
||||
snackbar.success();
|
||||
|
||||
$currentOrg.customDomain = undefined;
|
||||
$currentOrg.customDomain = '';
|
||||
$currentOrg.isCustomDomainVerified = false;
|
||||
isCustomDomainLoading = false;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -30,7 +28,7 @@
|
||||
console.log(error);
|
||||
snackbar.error('snackbar.course_settings.error.failed_integration');
|
||||
} else {
|
||||
$profile.telegram_chat_id = chatId;
|
||||
$profile.telegramChatId = chatId;
|
||||
chatId = null;
|
||||
snackbar.success('snackbar.course_settings.success.successful_integration');
|
||||
}
|
||||
@@ -43,7 +41,7 @@
|
||||
snackbar.error('snackbar.course_settings.error.failed_deletion');
|
||||
console.log(error);
|
||||
} else {
|
||||
$profile.telegram_chat_id = null;
|
||||
$profile.telegramChatId = null;
|
||||
snackbar.success('snackbar.course_settings.success.successful_deletion');
|
||||
}
|
||||
}
|
||||
@@ -57,7 +55,7 @@
|
||||
>
|
||||
|
||||
<Column sm={8} md={8} lg={8} class="mt-2 flex flex-col items-center lg:mt-0 lg:items-start">
|
||||
{#if $profile.telegram_chat_id !== null}
|
||||
{#if $profile.telegramChatId !== null}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<div class="flex items-center">
|
||||
<CircleCheckIcon size={16} filled />
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
@@ -23,6 +21,7 @@
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
|
||||
let isSaving = $state(false);
|
||||
let creatingNewQuestion = $state(false);
|
||||
@@ -154,7 +153,7 @@
|
||||
// Set default from store
|
||||
currentOrg.subscribe((cOrg) => {
|
||||
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
|
||||
const landingpage = { ...cOrg.landingpage };
|
||||
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
|
||||
|
||||
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
|
||||
if (!landingpage?.header?.banner) {
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { t, initialized } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: LOCALE;
|
||||
value?: TLocale;
|
||||
hasLangChanged?: boolean;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
let { className = '', value = $bindable(LOCALE.EN), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
|
||||
function handleSelect(selectedValue: string) {
|
||||
value = selectedValue as LOCALE;
|
||||
value = selectedValue as TLocale;
|
||||
hasLangChanged = true;
|
||||
change?.();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -13,9 +13,7 @@
|
||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
|
||||
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
@@ -119,8 +117,8 @@
|
||||
if (data) {
|
||||
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
|
||||
updates.avatar_url = response.publicUrl;
|
||||
$currentOrg.avatar_url = response.publicUrl;
|
||||
updates.avatarUrl = response.publicUrl;
|
||||
$currentOrg.avatarUrl = response.publicUrl;
|
||||
}
|
||||
avatar = undefined;
|
||||
}
|
||||
@@ -174,7 +172,7 @@
|
||||
/>
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={$currentOrg.avatar_url}
|
||||
src={$currentOrg.avatarUrl}
|
||||
shape="rounded-md"
|
||||
widthHeight="w-24 h-24"
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
|
||||
@@ -1,94 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import type { ProfileStore } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { CircleCheckBig } from '@lucide/svelte';
|
||||
import { profileApi } from '$lib/features/auth/api/profile.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { updateProfileValidation } from '$lib/utils/functions/validator';
|
||||
import { handleLocaleChange, t } from '$lib/utils/functions/translations';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Row from './Layout/Row.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import LanguagePicker from './LanguagePicker.svelte';
|
||||
import generateUUID from '$lib/utils/functions/generateUUID';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
|
||||
let avatar = $state<string | undefined>();
|
||||
let loading = $state(false);
|
||||
let avatar = $state<string | File | undefined>();
|
||||
let hasLangChanged = $state(false);
|
||||
let locale = $derived<LOCALE | undefined>($profile.locale);
|
||||
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
let email = $derived($profile.email || '');
|
||||
let isChangingEmail = $state(false);
|
||||
let emailChangeInitiated = $state(false);
|
||||
|
||||
async function handleUpdate() {
|
||||
errors = updateProfileValidation($profile);
|
||||
if (Object.values(errors).some((error) => error)) {
|
||||
loading = false;
|
||||
await profileApi.submit(
|
||||
{
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username,
|
||||
locale,
|
||||
avatar
|
||||
},
|
||||
$profile,
|
||||
hasLangChanged,
|
||||
locale
|
||||
);
|
||||
|
||||
if (profileApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
avatar = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailChange() {
|
||||
if (email === $profile.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log({ hasLangChanged });
|
||||
loading = true;
|
||||
await profileApi.changeEmail({
|
||||
newEmail: email,
|
||||
callbackURL: window.location.href
|
||||
});
|
||||
|
||||
const updates: Partial<ProfileStore> = {
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username,
|
||||
email: $profile.email,
|
||||
locale
|
||||
};
|
||||
console.log('updates', updates);
|
||||
|
||||
if (avatar) {
|
||||
const filename = `user/${generateUUID()}.webp`;
|
||||
|
||||
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
if (data) {
|
||||
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
|
||||
updates.avatar_url = response.publicUrl;
|
||||
$profile.avatar_url = response.publicUrl;
|
||||
}
|
||||
|
||||
avatar = '';
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profile').update(updates).match({ id: $profile.id });
|
||||
|
||||
profile.update((_profile) => ({
|
||||
..._profile,
|
||||
...updates
|
||||
}));
|
||||
snackbar.success('snackbar.course_settings.success.update_successful');
|
||||
|
||||
if (hasLangChanged && locale) {
|
||||
handleLocaleChange(locale);
|
||||
}
|
||||
|
||||
hasUnsavedChanges = false;
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : `${error}`;
|
||||
|
||||
if (message.includes('profile_username_key')) {
|
||||
message = $t('snackbar.lms.error.username_exists');
|
||||
}
|
||||
|
||||
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
|
||||
|
||||
loading = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
if (profileApi.success) {
|
||||
isChangingEmail = false;
|
||||
emailChangeInitiated = true;
|
||||
// Reset email field to current email after successful change request
|
||||
email = $profile.email || '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -103,8 +70,9 @@
|
||||
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={$profile.avatar_url}
|
||||
src={$profile.avatarUrl}
|
||||
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
|
||||
isDisabled={profileApi.isLoading}
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Column>
|
||||
@@ -118,23 +86,64 @@
|
||||
label={$t('settings.profile.personal_information.full_name')}
|
||||
bind:value={$profile.fullname}
|
||||
className="w-full lg:w-60 mb-4"
|
||||
errorMessage={$t(errors.fullname)}
|
||||
errorMessage={profileApi.errors.fullname ? $t(profileApi.errors.fullname) : undefined}
|
||||
onChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<TextField
|
||||
label={$t('settings.profile.personal_information.username')}
|
||||
bind:value={$profile.username}
|
||||
className="w-full lg:w-60 mb-4"
|
||||
errorMessage={$t(errors.username)}
|
||||
onChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<TextField
|
||||
label={$t('settings.profile.personal_information.email')}
|
||||
bind:value={$profile.email}
|
||||
className="w-full lg:w-60 mb-4"
|
||||
errorMessage={$t(errors.email)}
|
||||
errorMessage={profileApi.errors.username ? $t(profileApi.errors.username) : undefined}
|
||||
onChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<div class="mb-4 w-full lg:w-60">
|
||||
<TextField
|
||||
label={$t('settings.profile.personal_information.email')}
|
||||
bind:value={email}
|
||||
className="w-full"
|
||||
type="email"
|
||||
isDisabled={profileApi.isLoading}
|
||||
errorMessage={profileApi.errors.newEmail ? $t(profileApi.errors.newEmail) : undefined}
|
||||
onChange={() => {
|
||||
if (email !== $profile.email) {
|
||||
isChangingEmail = true;
|
||||
emailChangeInitiated = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if isChangingEmail && email !== $profile.email}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<PrimaryButton
|
||||
label={$t('settings.profile.personal_information.confirm')}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
className="text-sm"
|
||||
isLoading={profileApi.isLoading}
|
||||
isDisabled={profileApi.isLoading}
|
||||
onClick={handleEmailChange}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={$t('settings.profile.personal_information.cancel')}
|
||||
variant={VARIANTS.NONE}
|
||||
className="text-sm text-primary-700"
|
||||
isDisabled={profileApi.isLoading}
|
||||
onClick={() => {
|
||||
email = $profile.email || '';
|
||||
isChangingEmail = false;
|
||||
emailChangeInitiated = false;
|
||||
profileApi.errors.newEmail = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if emailChangeInitiated}
|
||||
<div
|
||||
class="ml-2 mt-2 flex w-4/5 items-center gap-2 rounded-md border border-gray-200 p-2 text-sm text-amber-500"
|
||||
>
|
||||
<CircleCheckBig />
|
||||
<p>{$t('settings.profile.personal_information.email_change_verification_note')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<LanguagePicker
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
bind:hasLangChanged
|
||||
@@ -149,8 +158,7 @@
|
||||
label={$t('settings.profile.update_profile')}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
className="mr-5 w-fit"
|
||||
isLoading={loading}
|
||||
isDisabled={loading}
|
||||
isLoading={profileApi.isLoading}
|
||||
onClick={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
@@ -12,18 +12,17 @@
|
||||
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, ROLE } from '$lib/utils/constants/roles';
|
||||
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 from './Layout/Row.svelte';
|
||||
import Grid from './Layout/Grid.svelte';
|
||||
import Column from './Layout/Column.svelte';
|
||||
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/components/ComingSoon/index.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
let emailsStr = $state('');
|
||||
@@ -190,7 +189,7 @@
|
||||
>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
{#if isFetching}
|
||||
<Moon />
|
||||
<Circle />
|
||||
{:else}
|
||||
{#each $orgTeam as teamMember}
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user