Implement Cloudflare custom domain management flow

- Added support for connecting, refreshing, and removing custom domains through Cloudflare.
- Updated API routes and validation schemas to handle new domain actions.
- Refactored domain management logic in the dashboard to accommodate Cloudflare integration.
- Enhanced user experience with clear status messages and DNS instructions for domain setup.
- Updated translations and UI components to reflect new domain management features.
This commit is contained in:
Rotimi Best
2026-03-27 09:12:53 +01:00
parent 3919ff6c2f
commit f3d212f984
23 changed files with 1376 additions and 244 deletions
+4
View File
@@ -8,6 +8,10 @@ const envSchema = z.object({
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
+42 -31
View File
@@ -1,49 +1,60 @@
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '@api/services/org/domain';
import { ZDomainActionRequest } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import {
assertSupportedCustomDomain,
connectDomain,
normalizeCustomDomain,
refreshDomain,
removeDomain
} from '@api/services/org/domain';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { updateOrg } from '@api/services/organization';
import { handleError } from '@api/utils/errors';
import { z } from 'zod';
const ZDomainRequest = z.object({
params: z.object({
key: z.enum(['verify_domain', 'add_domain', 'remove_domain']),
domain: z.string().min(1)
})
});
import { zValidator } from '@hono/zod-validator';
export const domainRouter = new Hono()
/**
* POST /domain
* Handles domain operations: verify, add, or remove domain
* Requires authentication
* Handles domain operations: connect, refresh, or remove
* Requires authentication and organization admin access
*/
.post('/', authMiddleware, async (c) => {
.post('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZDomainActionRequest), async (c) => {
try {
const body = await c.req.json();
const validatedData = ZDomainRequest.parse(body);
const orgId = c.req.header('cio-org-id')!;
const { action, domain } = c.req.valid('json');
const { key, domain } = validatedData.params;
const normalizedDomain = normalizeCustomDomain(domain);
assertSupportedCustomDomain(normalizedDomain);
if (domain.includes('classroomio.com')) {
return c.json({ success: false }, 400);
}
switch (action) {
case 'connect': {
const result = await connectDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
switch (key) {
case 'verify_domain': {
const configResponse = await getConfigResponse(domain);
return c.json({ success: true, verified: !configResponse.misconfigured }, 200);
return c.json({ success: true, data: result }, 200);
}
case 'add_domain': {
const addDomainResponse = await addDomainToVercel(domain);
return c.json({ success: true, data: addDomainResponse }, 200);
case 'refresh': {
const result = await refreshDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
return c.json({ success: true, data: result }, 200);
}
case 'remove_domain': {
const removeDomainResponse = await removeDomainFromVercelProject(domain);
return c.json({ success: true, data: removeDomainResponse }, 200);
case 'remove': {
const result = await removeDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: null,
isCustomDomainVerified: false
});
return c.json({ success: true, data: result }, 200);
}
default:
return c.json({ success: false, message: 'Invalid request' }, 400);
}
} catch (error) {
return handleError(c, error, 'Failed to process domain request');
+317 -40
View File
@@ -1,48 +1,325 @@
// Code from https://github.com/vercel/platforms/blob/1ed55b13bcae97b037c69ec40b4c32df21c2412c/lib/domains.ts
import { env } from '@api/config/env';
import { AppError, ErrorCodes } from '@api/utils/errors';
export const addDomainToVercel = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v10/projects/${env.PROJECT_ID_VERCEL}/domains${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: domain
})
export type DomainSetupStatus =
| 'reconnect_required'
| 'pending_dns'
| 'pending_verification'
| 'verified'
| 'removed'
| 'error';
export interface DomainDnsRecord {
type: 'CNAME' | 'TXT';
name: string;
value: string;
status: 'pending' | 'active';
}
export interface DomainSetupResult {
hostname: string;
status: DomainSetupStatus;
verified: boolean;
reconnectRequired: boolean;
message: string;
provider: 'cloudflare';
dnsRecords: DomainDnsRecord[];
}
interface CloudflareApiError {
code: number;
message: string;
}
interface CloudflareEnvelope<T> {
success: boolean;
errors: CloudflareApiError[];
messages: Array<{ code: number; message: string }>;
result: T;
}
interface CloudflareValidationRecord {
status?: string;
txt_name?: string;
txt_value?: string;
}
interface CloudflareCustomHostname {
id: string;
hostname: string;
status?: string;
ownership_verification?: {
type?: string;
name?: string;
value?: string;
};
ssl?: {
status?: string;
validation_records?: CloudflareValidationRecord[];
};
}
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
function ensureCloudflareConfig() {
const missing = [
['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN],
['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID],
['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET]
].filter(([, value]) => !value);
if (missing.length > 0) {
throw new AppError(
`Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`,
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
function getCloudflareBasePath() {
ensureCloudflareConfig();
return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`;
}
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${getCloudflareBasePath()}${path}`, {
...init,
headers: {
Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`,
'Content-Type': 'application/json',
...(init.headers ?? {})
}
).then((res) => res.json());
};
});
export const removeDomainFromVercelProject = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`
},
method: 'DELETE'
const payload = (await response.json()) as CloudflareEnvelope<T>;
if (!response.ok || !payload.success) {
const message =
payload.errors?.map((error) => error.message).join(', ') ||
payload.messages?.map((item) => item.message).join(', ') ||
'Cloudflare domain request failed';
throw new AppError(
message,
response.status === 409 ? ErrorCodes.CONFLICT : ErrorCodes.INTERNAL_ERROR,
(response.status || 500) as 400 | 401 | 403 | 404 | 409 | 422 | 500
);
}
return payload.result;
}
function dedupeDnsRecords(records: DomainDnsRecord[]) {
const seen = new Set<string>();
return records.filter((record) => {
const key = `${record.type}:${record.name}:${record.value}`;
if (seen.has(key)) {
return false;
}
).then((res) => res.json());
};
export const getConfigResponse = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v6/domains/${domain}/config${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`,
seen.add(key);
return true;
});
}
function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] {
const records: DomainDnsRecord[] = [
{
method: 'GET',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
type: 'CNAME',
name: hostname,
value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!,
status: customHostname?.status === 'active' ? 'active' : 'pending'
}
];
if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) {
records.push({
type: 'TXT',
name: customHostname.ownership_verification.name,
value: customHostname.ownership_verification.value,
status: customHostname.status === 'active' ? 'active' : 'pending'
});
}
customHostname?.ssl?.validation_records?.forEach((record) => {
if (!record.txt_name || !record.txt_value) {
return;
}
records.push({
type: 'TXT',
name: record.txt_name,
value: record.txt_value,
status: record.status === 'active' ? 'active' : 'pending'
});
});
return dedupeDnsRecords(records);
}
function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus {
if (!customHostname) {
return 'reconnect_required';
}
if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') {
return 'verified';
}
const hasOwnershipRecord = Boolean(
customHostname.ownership_verification?.name && customHostname.ownership_verification?.value
);
if (hasOwnershipRecord) {
return 'pending_dns';
}
const hasSslValidationRecords = Boolean(
customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value)
);
if (hasSslValidationRecords || customHostname.ssl?.status) {
return 'pending_verification';
}
return 'error';
}
function getStatusMessage(status: DomainSetupStatus) {
switch (status) {
case 'reconnect_required':
return 'Reconnect this custom domain to generate new Cloudflare setup records.';
case 'pending_dns':
return 'Add the DNS records below, then refresh verification.';
case 'pending_verification':
return 'DNS records were found. SSL issuance or validation is still in progress.';
case 'verified':
return 'Custom domain verified.';
case 'removed':
return 'Custom domain removed.';
case 'error':
default:
return 'Custom domain exists, but verification is not complete yet.';
}
}
function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult {
const status = mapDomainStatus(customHostname);
return {
hostname,
status,
verified: status === 'verified',
reconnectRequired: status === 'reconnect_required',
message: getStatusMessage(status),
provider: 'cloudflare',
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname)
};
}
export function normalizeCustomDomain(domain: string) {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
}
export function assertSupportedCustomDomain(domain: string) {
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
throw new AppError(
'Only custom subdomains are supported. Use a subdomain like courses.yourwebsite.com.',
ErrorCodes.VALIDATION_ERROR,
400,
'domain'
);
}
if (domain.includes('classroomio.com')) {
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
}
}
async function getCustomHostnameByDomain(hostname: string) {
const results = await cloudflareRequest<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
method: 'GET'
});
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
}
async function createCustomHostname(hostname: string) {
const body: Record<string, unknown> = {
hostname,
ssl: {
method: 'txt',
type: 'dv'
}
};
if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) {
body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN;
}
return cloudflareRequest<CloudflareCustomHostname>('', {
method: 'POST',
body: JSON.stringify(body)
});
}
async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) {
return cloudflareRequest<CloudflareCustomHostname>(`/${customHostname.id}`, {
method: 'PATCH',
body: JSON.stringify({
ssl: {
method: 'txt',
type: 'dv'
}
}
).then((res) => res.json());
};
})
});
}
export async function connectDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
const customHostname = existing ?? (await createCustomHostname(hostname));
return toDomainSetupResult(hostname, customHostname);
}
export async function refreshDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (!existing) {
return toDomainSetupResult(hostname, null);
}
if (existing.status === 'active' || existing.ssl?.status === 'active') {
return toDomainSetupResult(hostname, existing);
}
const refreshed = await revalidateCustomHostname(existing);
return toDomainSetupResult(hostname, refreshed);
}
export async function removeDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (existing) {
await cloudflareRequest<null>(`/${existing.id}`, {
method: 'DELETE'
});
}
return {
hostname,
status: 'removed',
verified: false,
reconnectRequired: false,
message: getStatusMessage('removed'),
provider: 'cloudflare',
dnsRecords: []
};
}
@@ -353,23 +353,21 @@ class OrgApi extends BaseApiWithErrors {
}
/**
* Sends a domain request (verify, add, or remove domain)
* @param key Domain operation key: 'verify_domain', 'add_domain', or 'remove_domain'
* Sends a domain request (connect, refresh, or remove)
* @param action Domain operation action
* @param domain Domain name
* @returns Domain request response data
*/
async sendDomainRequest(key: 'verify_domain' | 'add_domain' | 'remove_domain', domain: string) {
async sendDomainRequest(action: 'connect' | 'refresh' | 'remove', domain: string) {
return this.execute<DomainRequestRequest>({
requestFn: () =>
classroomio.domain.$post({
json: {
params: {
key,
domain
}
action,
domain
}
}),
logContext: `processing domain request: ${key}`,
logContext: `processing domain request: ${action}`,
onSuccess: () => {
this.success = true;
this.errors = {};
@@ -53,6 +53,8 @@ export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { su
export type DomainRequestRequest = typeof classroomio.domain.$post;
export type DomainRequestResponse = InferResponseType<DomainRequestRequest>;
export type DomainRequestSuccess = Extract<DomainRequestResponse, { success: true }>;
export type DomainRequestData = DomainRequestSuccess['data'];
export type DomainRequestStatus = DomainRequestData['status'];
// SSO types
export type GetSsoConfigRequest = (typeof classroomio.organization)['sso']['$get'];
@@ -1,11 +1,12 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import Copy from '@lucide/svelte/icons/copy';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import TrashIcon from '@lucide/svelte/icons/trash';
import Copy from '@lucide/svelte/icons/copy';
import type { DomainRequestData, DomainRequestStatus } from '$features/org/utils/types';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
@@ -31,13 +32,19 @@
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
let domainSetup = $state<DomainRequestData | null>(null);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
const currentDomainStatus = $derived<DomainRequestStatus | null>(
domainSetup?.status ??
($currentOrg.customDomain ? ($currentOrg.isCustomDomainVerified ? 'verified' : 'pending_verification') : null)
);
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
@@ -55,6 +62,7 @@
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
await orgApi.update($currentOrg.id, {
@@ -64,29 +72,93 @@
if (orgApi.success) {
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
} else if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
errors.siteName = $t('add_org.sitename');
}
errors.siteName = $t('add_org.sitename');
}
isLoading = false;
}
function applyDomainSetup(data: DomainRequestData) {
domainSetup = data;
if (data.status === 'removed') {
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
return;
}
$currentOrg.customDomain = data.hostname;
$currentOrg.isCustomDomainVerified = data.verified;
customDomain = '';
}
function getDomainStatusLabel(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return $t('components.settings.domains.status_verified');
case 'pending_dns':
return $t('components.settings.domains.status_pending_dns');
case 'pending_verification':
return $t('components.settings.domains.status_pending_verification');
case 'reconnect_required':
return $t('components.settings.domains.status_reconnect_required');
case 'error':
return $t('components.settings.domains.status_error');
case 'removed':
return $t('components.settings.domains.status_removed');
default:
return '';
}
}
function getDomainStatusClass(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return 'bg-green-500 text-white';
case 'pending_dns':
case 'pending_verification':
return 'bg-yellow-500 text-white';
case 'reconnect_required':
case 'error':
return 'bg-red-500 text-white';
case 'removed':
return 'bg-slate-500 text-white';
default:
return 'bg-yellow-500 text-white';
}
}
function getDomainStatusDescription(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return $t('components.settings.domains.verified_description');
case 'pending_dns':
return $t('components.settings.domains.pending_dns_description');
case 'pending_verification':
return $t('components.settings.domains.pending_verification_description');
case 'reconnect_required':
return $t('components.settings.domains.reconnect_description');
case 'error':
return $t('components.settings.domains.error_description');
default:
return $t('components.settings.domains.dns_description');
}
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.customDomain = 'Custom domains are only available on paid plans';
return;
}
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
@@ -99,31 +171,39 @@
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: sanitizedDomain
});
try {
const response = await sendDomainRequest('connect', sanitizedDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to connect domain');
}
if (!orgApi.success) {
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: connecting domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
return;
}
}
async function handleReconnectCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
try {
const data = await sendDomainRequest('add_domain', sanitizedDomain);
if (!data.success) {
throw new Error(data.message || 'Failed to add domain');
const response = await sendDomainRequest('connect', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to reconnect domain');
}
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error instanceof Error ? error.message : (error as string));
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: reconnecting domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
}
}
async function handleRemoveCustomDomain() {
@@ -131,57 +211,41 @@
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: null,
isCustomDomainVerified: false
});
if (!orgApi.success) {
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
isCustomDomainLoading = false;
return;
}
try {
const data = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
if (!data.success) {
throw new Error(data.message || 'Failed to remove domain');
const response = await sendDomainRequest('remove', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to remove domain');
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error instanceof Error ? error.message : (error as string));
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: removing domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
}
}
async function handleRefreshCustomDomain() {
async function handleRefreshCustomDomain(silent = false) {
if (!$currentOrg.customDomain) return;
isRefreshing = true;
try {
const data = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
await orgApi.update($currentOrg.id, {
isCustomDomainVerified: true
});
if (orgApi.success) {
$currentOrg.isCustomDomainVerified = true;
}
const response = await sendDomainRequest('refresh', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to refresh domain');
}
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
if (!silent) {
snackbar.error(error instanceof Error ? error.message : String(error));
}
} finally {
isRefreshing = false;
}
isRefreshing = false;
}
function resetErrors(_siteName: string | null, _customDomain: string | null) {
@@ -189,16 +253,18 @@
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
onMount(() => {
if ($currentOrg.customDomain) {
void handleRefreshCustomDomain(true);
}
});
$effect(() => {
resetErrors(siteName, customDomain);
@@ -224,6 +290,7 @@
<Field.Error>{errors.siteName}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
{$t('components.settings.domains.update')}
@@ -237,10 +304,11 @@
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
<Field.Group>
{#if $currentOrg.customDomain}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
@@ -249,56 +317,88 @@
</Button>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
class={`mt-1 h-2 w-2 rounded-full ${
currentDomainStatus === 'verified'
? 'bg-green-400'
: currentDomainStatus === 'reconnect_required' || currentDomainStatus === 'error'
? 'bg-red-400'
: 'bg-yellow-400'
}`}
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
{:else}
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
{/if}
<Badge variant="default" class={`px-3 text-xs ${getDomainStatusClass(currentDomainStatus)}`}>
{getDomainStatusLabel(currentDomainStatus)}
</Badge>
</div>
</Field.Field>
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
<Field.Description>{getDomainStatusDescription(currentDomainStatus)}</Field.Description>
<Field.Field>
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p class="flex h-[40px] items-center">CNAME</p>
{#if domainSetup?.dnsRecords?.length}
<Field.Field>
<div class="space-y-3 rounded-md border px-4 py-3">
{#each domainSetup.dnsRecords as record}
<div class="grid gap-3 sm:grid-cols-[80px,1fr,1fr] sm:items-center">
<div>
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p>{record.type}</p>
</div>
<div class="min-w-0">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<div class="flex items-center gap-2">
<p class="truncate">{record.name}</p>
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard(record.name)}>
<Copy />
</Button>
</div>
</div>
<div class="min-w-0">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<div class="flex items-center gap-2">
<p class="truncate">{record.value}</p>
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard(record.value)}>
<Copy />
</Button>
</div>
</div>
</div>
{/each}
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</Field.Field>
{:else if currentDomainStatus !== 'verified'}
<Field.Field>
<div class="rounded-md border px-4 py-3 text-sm ui:text-muted-foreground">
{$t('components.settings.domains.reconnect_hint')}
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</Button>
</p>
</div>
</div>
</Field.Field>
</Field.Field>
{/if}
<Field.Field orientation="horizontal">
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleRefreshCustomDomain}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
{#if currentDomainStatus === 'reconnect_required'}
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleReconnectCustomDomain}
loading={isCustomDomainLoading}
>
{$t('components.settings.domains.reconnect')}
</Button>
{:else}
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={() => handleRefreshCustomDomain()}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
{/if}
<Button
variant="destructive"
@@ -1,27 +1,25 @@
import type { DomainRequestData } from '$features/org/utils/types';
import { orgApi } from '$features/org/api/org.svelte';
export const sanitizeDomain = (domain: string) => {
return domain
.trim()
.toLowerCase()
.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
.split('/')[0];
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
};
export async function sendDomainRequest(
key: 'verify_domain' | 'add_domain' | 'remove_domain',
action: 'connect' | 'refresh' | 'remove',
domain: string
): Promise<{ success: boolean; data?: unknown; verified?: boolean; message?: string }> {
const response = await orgApi.sendDomainRequest(key, domain);
if (orgApi.success && response) {
const data = 'data' in response ? response.data : null;
const verified = 'verified' in response ? response.verified : undefined;
): Promise<{ success: boolean; data?: DomainRequestData; message?: string }> {
const response = await orgApi.sendDomainRequest(action, domain);
if (orgApi.success && response?.data) {
return {
success: true,
data,
verified
data: response.data
};
}
@@ -311,6 +311,7 @@
"creating_new": "Oprettelse af nye oversættelser mislykkedes"
},
"exercise": {
"points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.",
"success": "Gemt",
"submission_updated": "Indsendelse opdateret til",
"error_fetching": "Hentning af øvelser mislykkedes. Genindlæs venligst siden"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certifikater",
"certificate_settings": "Certifikatindstillinger",
"tab_design": "Design",
"tab_settings": "Indstillinger",
"theme": "Vælg et tema",
"logo": "Brand Logo",
"to_update": "Gå til for at opdatere dit brandimage",
@@ -1071,6 +1074,9 @@
"finish": "Afslut",
"next": "Næste",
"check": "Tjek",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Indsendelser",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Links"
},
"select_type": "Vælg type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Opgrader for at bruge denne spørgsmålstype"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certifikater",
"nav_landing_page": "Landingsside",
"nav_settings": "Indstillinger"
},
"completion": {
"modal": {
"title": "Kursuskravene er fuldført!",
"subtitle": "Tillykke – du kan se og downloade dit certifikat når som helst.",
"view_certificate": "Se certifikat",
"later": "Luk"
}
},
"certification": {
"rules_heading": "Certificeringsregler",
"deadline_label": "Certificeringsfrist",
"deadline_helper": "Studerende skal opfylde fuldførelsesprocenten inden denne dato (lokal tid). Lad være tomt uden deadline.",
"threshold_label": "Minimum fuldførelse (%)",
"threshold_helper": "Kombineret fremskridt i lektion og træning kræves for at opnå certifikatet (0-100). Standard er 100 %.",
"email_message_label": "E-mail med certificering",
"email_message_helper": "Valgfri besked vist i e-mailen, når en elev opfylder kravene. Et link til certifikatsiden er altid inkluderet.",
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!"
},
"certificates": {
"progress_hint": "{current} % gennemført ({required} % kræves for at downloade)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Opdater",
"remove": "Fjern",
"custom_favicon": "Brugerdefineret favicon",
"custom_code": "Brugerdefineret kode"
"custom_code": "Brugerdefineret kode",
"reconnect": "Gentilslut domæne",
"reconnect_hint": "Gentilslut dette domæne for at generere nye Cloudflare-opsætningsposter.",
"status_verified": "Verificeret",
"status_pending_dns": "Afventende DNS",
"status_pending_verification": "Afventer bekræftelse",
"status_reconnect_required": "Genopret forbindelse påkrævet",
"status_error": "Har brug for opmærksomhed",
"status_removed": "Fjernet",
"verified_description": "Dette tilpassede domæne er aktivt og bekræftet.",
"pending_dns_description": "Tilføj DNS-posterne nedenfor, og opdater derefter bekræftelsen.",
"pending_verification_description": "DNS-poster blev fundet. SSL-udstedelse eller -validering er stadig i gang.",
"reconnect_description": "Dette domæne skal forbindes igen gennem det nye Cloudflare-opsætningsflow.",
"error_description": "Dette tilpassede domæne eksisterer, men bekræftelsen er ikke fuldført endnu."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Das Erstellen neuer Übersetzungen ist fehlgeschlagen"
},
"exercise": {
"points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.",
"success": "Erfolgreich gespeichert",
"submission_updated": "Einreichung aktualisiert auf",
"error_fetching": "Das Abrufen der Übungen ist fehlgeschlagen. Bitte laden Sie die Seite neu"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Zertifikate",
"certificate_settings": "Zertifikatseinstellungen",
"tab_design": "Design",
"tab_settings": "Einstellungen",
"theme": "Wählen Sie ein Thema",
"logo": "Markenlogo",
"to_update": "Um Ihr Markenimage zu aktualisieren, gehen Sie zu",
@@ -1071,6 +1074,9 @@
"finish": "Fertig",
"next": "Als nächstes",
"check": "Überprüfen",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Einsendungen",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Links"
},
"select_type": "Typ auswählen",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Führen Sie ein Upgrade durch, um diesen Fragetyp zu verwenden"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Zertifikate",
"nav_landing_page": "Landingpage",
"nav_settings": "Einstellungen"
},
"completion": {
"modal": {
"title": "Kursvoraussetzungen erfüllt!",
"subtitle": "Herzlichen Glückwunsch – Sie können Ihr Zertifikat jederzeit einsehen und herunterladen.",
"view_certificate": "Zertifikat ansehen",
"later": "Schließen"
}
},
"certification": {
"rules_heading": "Zertifizierungsregeln",
"deadline_label": "Zertifizierungsfrist",
"deadline_helper": "Die Schüler müssen den Abschlussprozentsatz vor diesem Datum/Uhrzeit (Ortszeit) erreichen. Für keine Frist leer lassen.",
"threshold_label": "Mindestabschluss (%)",
"threshold_helper": "Kombinierter Unterrichts- und Übungsfortschritt, der zum Erwerb des Zertifikats erforderlich ist (0–100). Der Standardwert ist 100 %.",
"email_message_label": "E-Mail-Nachricht zur Zertifizierung",
"email_message_helper": "Optionale Nachricht, die in der E-Mail angezeigt wird, wenn ein Student die Anforderungen erfüllt. Ein Link zur Zertifikatsseite ist immer enthalten.",
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!"
},
"certificates": {
"progress_hint": "{current} % abgeschlossen ({required} % zum Herunterladen erforderlich)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Aktualisieren",
"remove": "Entfernen",
"custom_favicon": "Benutzerdefiniertes Favicon",
"custom_code": "Benutzerdefinierter Code"
"custom_code": "Benutzerdefinierter Code",
"reconnect": "Domäne erneut verbinden",
"reconnect_hint": "Verbinden Sie diese Domäne erneut, um neue Cloudflare-Setup-Datensätze zu generieren.",
"status_verified": "Verifiziert",
"status_pending_dns": "Ausstehendes DNS",
"status_pending_verification": "Ausstehende Überprüfung",
"status_reconnect_required": "Erneute Verbindung erforderlich",
"status_error": "Braucht Aufmerksamkeit",
"status_removed": "Entfernt",
"verified_description": "Diese benutzerdefinierte Domäne ist aktiv und überprüft.",
"pending_dns_description": "Fügen Sie die DNS-Einträge unten hinzu und aktualisieren Sie dann die Überprüfung.",
"pending_verification_description": "DNS-Einträge wurden gefunden. Die SSL-Ausstellung oder -Validierung ist noch im Gange.",
"reconnect_description": "Diese Domäne muss über den neuen Cloudflare-Setup-Ablauf erneut verbunden werden.",
"error_description": "Diese benutzerdefinierte Domäne existiert, aber die Überprüfung ist noch nicht abgeschlossen."
}
},
"quiz": {
@@ -777,7 +777,20 @@
"dns_value": "Value",
"dns_name": "Name",
"refresh": "Refresh",
"reconnect": "Reconnect domain",
"reconnect_hint": "Reconnect this domain to generate new Cloudflare setup records.",
"remove": "Remove",
"status_verified": "Verified",
"status_pending_dns": "Pending DNS",
"status_pending_verification": "Pending verification",
"status_reconnect_required": "Reconnect required",
"status_error": "Needs attention",
"status_removed": "Removed",
"verified_description": "This custom domain is active and verified.",
"pending_dns_description": "Add the DNS records below, then refresh verification.",
"pending_verification_description": "DNS records were found. SSL issuance or validation is still in progress.",
"reconnect_description": "This domain needs to be connected again through the new Cloudflare setup flow.",
"error_description": "This custom domain exists, but verification is not complete yet.",
"custom_favicon": "Custom favicon",
"custom_code": "Custom code"
},
@@ -961,6 +974,27 @@
"placeholder": "Search lessons or exercises",
"empty": "No results found"
},
"certificates": {
"progress_hint": "{current}% complete ({required}% required to download)"
},
"certification": {
"rules_heading": "Certification rules",
"deadline_label": "Certification deadline",
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
"threshold_label": "Minimum completion (%)",
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
"email_message_label": "Certification email message",
"email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.",
"email_message_placeholder": "e.g. We are proud of your progress — keep learning!"
},
"completion": {
"modal": {
"title": "Course requirements complete!",
"subtitle": "Congratulations — you can view and download your certificate anytime.",
"view_certificate": "View certificate",
"later": "Close"
}
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
@@ -1261,6 +1295,9 @@
"finish": "Finish",
"next": "Next",
"check": "Check",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"description": {
"title": "Title",
"heading": "Description",
@@ -1279,6 +1316,8 @@
"no": "No, cancel",
"yes": "Yes, delete",
"select_type": "Select type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Upgrade to use this question type",
"deleting": "Deleting...",
"error": "You have some errors",
@@ -1776,6 +1815,8 @@
"certificates": {
"title": "Certificates",
"certificate_settings": "Certificate Settings",
"tab_design": "Design",
"tab_settings": "Settings",
"theme": "Choose a theme",
"logo": "Brand Logo",
"to_update": "To update your brand image, go to",
@@ -2303,6 +2344,7 @@
}
},
"exercise": {
"points_required_auto_grade": "Set a positive point value for every question before saving.",
"success": "Saved Successfully",
"submission_updated": "Submission updated to",
"error_fetching": "Fetching exercises failed, please reload the page"
@@ -311,6 +311,7 @@
"creating_new": "Error al crear nuevas traducciones"
},
"exercise": {
"points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.",
"success": "Guardado exitosamente",
"submission_updated": "Envío actualizado a",
"error_fetching": "No se han podido recuperar los ejercicios. Vuelva a cargar la página."
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificados",
"certificate_settings": "Configuración del certificado",
"tab_design": "Diseño",
"tab_settings": "Configuración",
"theme": "Elige un tema",
"logo": "Logotipo de la marca",
"to_update": "Para actualizar su imagen de marca, vaya a",
@@ -1071,6 +1074,9 @@
"finish": "terminar",
"next": "Siguiente",
"check": "comprobar",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envíos",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Enlaces"
},
"select_type": "Seleccionar tipo",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Actualice para usar este tipo de pregunta"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página de destino",
"nav_settings": "Configuración"
},
"completion": {
"modal": {
"title": "¡Requisitos del curso completos!",
"subtitle": "Felicitaciones, puede ver y descargar su certificado en cualquier momento.",
"view_certificate": "Ver certificado",
"later": "Cerrar"
}
},
"certification": {
"rules_heading": "Reglas de certificación",
"deadline_label": "Fecha límite de certificación",
"deadline_helper": "Los estudiantes deben alcanzar el porcentaje de finalización antes de esta fecha (hora local). Déjelo vacío sin fecha límite.",
"threshold_label": "Finalización mínima (%)",
"threshold_helper": "Progreso combinado de lección y ejercicio requerido para obtener el certificado (0–100). El valor predeterminado es 100%.",
"email_message_label": "Mensaje de correo electrónico de certificación",
"email_message_helper": "Mensaje opcional que se muestra en el correo electrónico cuando un estudiante cumple con los requisitos. Siempre se incluye un enlace a la página del certificado.",
"email_message_placeholder": "p.ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!"
},
"certificates": {
"progress_hint": "{current}% completado ({required}% requerido para descargar)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Actualizar",
"remove": "Quitar",
"custom_favicon": "favicon personalizado",
"custom_code": "código personalizado"
"custom_code": "código personalizado",
"reconnect": "Reconectar dominio",
"reconnect_hint": "Vuelva a conectar este dominio para generar nuevos registros de configuración de Cloudflare.",
"status_verified": "Verificado",
"status_pending_dns": "DNS pendiente",
"status_pending_verification": "Pendiente de verificación",
"status_reconnect_required": "Se requiere reconectar",
"status_error": "necesita atencion",
"status_removed": "Eliminado",
"verified_description": "Este dominio personalizado está activo y verificado.",
"pending_dns_description": "Agregue los registros DNS a continuación y luego actualice la verificación.",
"pending_verification_description": "Se encontraron registros DNS. La emisión o validación de SSL aún está en curso.",
"reconnect_description": "Este dominio debe conectarse nuevamente a través del nuevo flujo de configuración de Cloudflare.",
"error_description": "Este dominio personalizado existe, pero la verificación aún no se ha completado."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "La création de nouvelles traductions a échoué"
},
"exercise": {
"points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.",
"success": "Enregistré avec succès",
"submission_updated": "Soumission mise à jour pour",
"error_fetching": "La récupération des exercices a échoué, veuillez recharger la page"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificats",
"certificate_settings": "Paramètres du certificat",
"tab_design": "Conception",
"tab_settings": "Paramètres",
"theme": "Choisissez un thème",
"logo": "Logo de la marque",
"to_update": "Pour mettre à jour votre image de marque, rendez-vous sur",
@@ -1071,6 +1074,9 @@
"finish": "Terminer",
"next": "Suivant",
"check": "Vérifier",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Soumissions",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Liens"
},
"select_type": "Sélectionnez le type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Mettre à niveau pour utiliser ce type de question"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificats",
"nav_landing_page": "Page de destination",
"nav_settings": "Paramètres"
},
"completion": {
"modal": {
"title": "Exigences du cours terminées !",
"subtitle": "Félicitations : vous pouvez consulter et télécharger votre certificat à tout moment.",
"view_certificate": "Afficher le certificat",
"later": "Fermer"
}
},
"certification": {
"rules_heading": "Règles de certification",
"deadline_label": "Date limite de certification",
"deadline_helper": "Les étudiants doivent atteindre le pourcentage d'achèvement avant cette date/heure (heure locale). Laisser vide sans date limite.",
"threshold_label": "Achèvement minimum (%)",
"threshold_helper": "Progrès combiné des leçons et des exercices requis pour obtenir le certificat (0-100). La valeur par défaut est 100 %.",
"email_message_label": "Courriel de certification",
"email_message_helper": "Message facultatif affiché dans l'e-mail lorsqu'un étudiant répond aux exigences. Un lien vers la page du certificat est toujours inclus.",
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !"
},
"certificates": {
"progress_hint": "{current} % terminé ({required} % requis pour le téléchargement)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Actualiser",
"remove": "Supprimer",
"custom_favicon": "Icône de favori personnalisée",
"custom_code": "Code personnalisé"
"custom_code": "Code personnalisé",
"reconnect": "Reconnecter le domaine",
"reconnect_hint": "Reconnectez ce domaine pour générer de nouveaux enregistrements de configuration Cloudflare.",
"status_verified": "Vérifié",
"status_pending_dns": "DNS en attente",
"status_pending_verification": "En attente de vérification",
"status_reconnect_required": "Reconnexion requise",
"status_error": "A besoin d'attention",
"status_removed": "Supprimé",
"verified_description": "Ce domaine personnalisé est actif et vérifié.",
"pending_dns_description": "Ajoutez les enregistrements DNS ci-dessous, puis actualisez la vérification.",
"pending_verification_description": "Des enregistrements DNS ont été trouvés. L'émission ou la validation SSL est toujours en cours.",
"reconnect_description": "Ce domaine doit être à nouveau connecté via le nouveau flux de configuration Cloudflare.",
"error_description": "Ce domaine personnalisé existe, mais la vérification n'est pas encore terminée."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "नए अनुवाद बनाना विफल रहा"
},
"exercise": {
"points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।",
"success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया",
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें"
@@ -750,6 +751,8 @@
"certificates": {
"title": "प्रमाणपत्र",
"certificate_settings": "प्रमाणपत्र सेटिंग्स",
"tab_design": "डिज़ाइन",
"tab_settings": "सेटिंग्स",
"theme": "एक थीम चुनें",
"logo": "ब्रांड लोगो",
"to_update": "अपनी ब्रांड छवि को अद्यतन करने के लिए, पर जाएँ",
@@ -1071,6 +1074,9 @@
"finish": "ख़त्म करो",
"next": "अगला",
"check": "जांचें",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "प्रस्तुतियाँ",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "कड़ियाँ"
},
"select_type": "प्रकार चुनें",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "इस प्रश्न प्रकार का उपयोग करने के लिए अपग्रेड करें"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "प्रमाणपत्र",
"nav_landing_page": "लैंडिंग पृष्ठ",
"nav_settings": "सेटिंग्स"
},
"completion": {
"modal": {
"title": "पाठ्यक्रम की आवश्यकताएँ पूर्ण!",
"subtitle": "बधाई हो - आप अपना प्रमाणपत्र कभी भी देख और डाउनलोड कर सकते हैं।",
"view_certificate": "प्रमाणपत्र देखें",
"later": "बंद करें"
}
},
"certification": {
"rules_heading": "प्रमाणन नियम",
"deadline_label": "प्रमाणीकरण की समय सीमा",
"deadline_helper": "छात्रों को इस दिनांक समय (स्थानीय समय) से पहले पूर्णता प्रतिशत पूरा करना होगा। बिना किसी समय सीमा के खाली छोड़ दें.",
"threshold_label": "न्यूनतम पूर्णता (%)",
"threshold_helper": "प्रमाणपत्र (0-100) अर्जित करने के लिए संयुक्त पाठ और अभ्यास प्रगति की आवश्यकता है। डिफ़ॉल्ट 100% है.",
"email_message_label": "प्रमाणीकरण ईमेल संदेश",
"email_message_helper": "जब कोई छात्र आवश्यकताओं को पूरा करता है तो ईमेल में वैकल्पिक संदेश दिखाया जाता है। प्रमाणपत्र पृष्ठ का एक लिंक हमेशा शामिल किया जाता है।",
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!"
},
"certificates": {
"progress_hint": "{वर्तमान}% पूर्ण (डाउनलोड करने के लिए {आवश्यक}% आवश्यक)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "ताज़ा करें",
"remove": "हटाओ",
"custom_favicon": "कस्टम फ़ेविकॉन",
"custom_code": "कस्टम कोड"
"custom_code": "कस्टम कोड",
"reconnect": "डोमेन पुनः कनेक्ट करें",
"reconnect_hint": "नए क्लाउडफ़ेयर सेटअप रिकॉर्ड बनाने के लिए इस डोमेन को पुनः कनेक्ट करें।",
"status_verified": "सत्यापित",
"status_pending_dns": "लंबित डीएनएस",
"status_pending_verification": "सत्यापन लंबित है",
"status_reconnect_required": "पुनः कनेक्ट करना आवश्यक है",
"status_error": "ध्यान देने की जरूरत है",
"status_removed": "हटा दिया गया",
"verified_description": "यह कस्टम डोमेन सक्रिय और सत्यापित है.",
"pending_dns_description": "नीचे DNS रिकॉर्ड जोड़ें, फिर सत्यापन ताज़ा करें।",
"pending_verification_description": "डीएनएस रिकॉर्ड पाए गए. एसएसएल जारी करना या सत्यापन अभी भी जारी है।",
"reconnect_description": "इस डोमेन को नए क्लाउडफ़ेयर सेटअप फ़्लो के माध्यम से फिर से कनेक्ट करने की आवश्यकता है।",
"error_description": "यह कस्टम डोमेन मौजूद है, लेकिन सत्यापन अभी तक पूरा नहीं हुआ है।"
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Tworzenie nowych tłumaczeń nie powiodło się"
},
"exercise": {
"points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.",
"success": "Zapisano pomyślnie",
"submission_updated": "Zgłoszenie zaktualizowane do",
"error_fetching": "Pobieranie ćwiczeń nie powiodło się. Załaduj stronę ponownie"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certyfikaty",
"certificate_settings": "Ustawienia certyfikatu",
"tab_design": "Projekt",
"tab_settings": "Ustawienia",
"theme": "Wybierz motyw",
"logo": "Logo marki",
"to_update": "Aby zaktualizować wizerunek swojej marki, przejdź do",
@@ -1071,6 +1074,9 @@
"finish": "Zakończ",
"next": "Następny",
"check": "Sprawdź",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Zgłoszenia",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Linki"
},
"select_type": "Wybierz typ",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Uaktualnij, aby używać tego typu pytań"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certyfikaty",
"nav_landing_page": "Strona docelowa",
"nav_settings": "Ustawienia"
},
"completion": {
"modal": {
"title": "Wymagania dotyczące kursu zostały spełnione!",
"subtitle": "Gratulacje — możesz wyświetlić i pobrać swój certyfikat w dowolnym momencie.",
"view_certificate": "Zobacz certyfikat",
"later": "Zamknij"
}
},
"certification": {
"rules_heading": "Zasady certyfikacji",
"deadline_label": "Termin certyfikacji",
"deadline_helper": "Studenci muszą osiągnąć procent ukończenia przed tą datą (czasu lokalnego). Pozostaw puste bez terminu.",
"threshold_label": "Minimalne ukończenie (%)",
"threshold_helper": "Do uzyskania certyfikatu wymagany jest łączny postęp w lekcjach i ćwiczeniach (0–100). Wartość domyślna to 100%.",
"email_message_label": "Wiadomość e-mail z certyfikatem",
"email_message_helper": "Opcjonalna wiadomość wyświetlana w wiadomości e-mail, gdy uczeń spełni wymagania. Zawsze dołączany jest link do strony z certyfikatem.",
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!"
},
"certificates": {
"progress_hint": "{bieżący}% ukończenia ({required}% wymagane do pobrania)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Odśwież",
"remove": "Usuń",
"custom_favicon": "Niestandardowa favikona",
"custom_code": "Kod niestandardowy"
"custom_code": "Kod niestandardowy",
"reconnect": "Połącz ponownie domenę",
"reconnect_hint": "Połącz ponownie tę domenę, aby wygenerować nowe rekordy konfiguracji Cloudflare.",
"status_verified": "Zweryfikowano",
"status_pending_dns": "Oczekujące DNS-y",
"status_pending_verification": "Oczekuje na weryfikację",
"status_reconnect_required": "Wymagane ponowne połączenie",
"status_error": "Potrzebuje uwagi",
"status_removed": "Usunięto",
"verified_description": "Ta domena niestandardowa jest aktywna i zweryfikowana.",
"pending_dns_description": "Dodaj poniższe rekordy DNS, a następnie odśwież weryfikację.",
"pending_verification_description": "Znaleziono rekordy DNS. Wydanie lub weryfikacja protokołu SSL jest nadal w toku.",
"reconnect_description": "Ta domena musi zostać ponownie połączona poprzez nowy proces konfiguracji Cloudflare.",
"error_description": "Ta domena niestandardowa istnieje, ale weryfikacja nie została jeszcze ukończona."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Falha ao criar novas traduções"
},
"exercise": {
"points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.",
"success": "Salvo com sucesso",
"submission_updated": "Envio atualizado para",
"error_fetching": "Falha ao buscar exercícios. Atualize a página"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificados",
"certificate_settings": "Configurações de certificado",
"tab_design": "Design",
"tab_settings": "Configurações",
"theme": "Escolha um tema",
"logo": "Logotipo da marca",
"to_update": "Para atualizar a imagem da sua marca, acesse",
@@ -1071,6 +1074,9 @@
"finish": "Concluir",
"next": "Próximo",
"check": "Verifique",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envios",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Ligações"
},
"select_type": "Selecione o tipo",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Atualize para usar este tipo de pergunta"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página inicial",
"nav_settings": "Configurações"
},
"completion": {
"modal": {
"title": "Requisitos do curso concluídos!",
"subtitle": "Parabéns – você pode visualizar e baixar seu certificado a qualquer momento.",
"view_certificate": "Ver certificado",
"later": "Fechar"
}
},
"certification": {
"rules_heading": "Regras de certificação",
"deadline_label": "Prazo de certificação",
"deadline_helper": "Os alunos devem atingir o percentual de conclusão antes desta data e hora (horário local). Deixe em branco para não ter prazo.",
"threshold_label": "Conclusão mínima (%)",
"threshold_helper": "Progresso combinado de aulas e exercícios necessário para obter o certificado (0–100). O padrão é 100%.",
"email_message_label": "Mensagem de e-mail de certificação",
"email_message_helper": "Mensagem opcional mostrada no e-mail quando um aluno atende aos requisitos. Um link para a página do certificado está sempre incluído.",
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!"
},
"certificates": {
"progress_hint": "{current}% concluído ({required}% necessário para download)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Atualizar",
"remove": "Remover",
"custom_favicon": "Favicon personalizado",
"custom_code": "Código personalizado"
"custom_code": "Código personalizado",
"reconnect": "Reconectar domínio",
"reconnect_hint": "Reconecte este domínio para gerar novos registros de configuração da Cloudflare.",
"status_verified": "Verificado",
"status_pending_dns": "DNS pendente",
"status_pending_verification": "Verificação pendente",
"status_reconnect_required": "É necessário reconectar",
"status_error": "Precisa de atenção",
"status_removed": "Removido",
"verified_description": "Este domínio personalizado está ativo e verificado.",
"pending_dns_description": "Adicione os registros DNS abaixo e atualize a verificação.",
"pending_verification_description": "Registros DNS foram encontrados. A emissão ou validação de SSL ainda está em andamento.",
"reconnect_description": "Este domínio precisa ser conectado novamente por meio do novo fluxo de configuração da Cloudflare.",
"error_description": "Este domínio personalizado existe, mas a verificação ainda não foi concluída."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Не удалось создать новые переводы."
},
"exercise": {
"points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.",
"success": "Сохранено успешно",
"submission_updated": "Представление обновлено до",
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу."
@@ -750,6 +751,8 @@
"certificates": {
"title": "Сертификаты",
"certificate_settings": "Настройки сертификата",
"tab_design": "Дизайн",
"tab_settings": "Настройки",
"theme": "Выберите тему",
"logo": "Логотип бренда",
"to_update": "Чтобы обновить имидж вашего бренда, перейдите по ссылке",
@@ -1071,6 +1074,9 @@
"finish": "Готово",
"next": "Далее",
"check": "Проверить",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Материалы",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Ссылки"
},
"select_type": "Выберите тип",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Обновите, чтобы использовать этот тип вопроса"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Сертификаты",
"nav_landing_page": "Целевая страница",
"nav_settings": "Настройки"
},
"completion": {
"modal": {
"title": "Требования курса выполнены!",
"subtitle": "Поздравляем — вы можете просмотреть и скачать свой сертификат в любое время.",
"view_certificate": "Посмотреть сертификат",
"later": "Закрыть"
}
},
"certification": {
"rules_heading": "Правила сертификации",
"deadline_label": "Срок сертификации",
"deadline_helper": "Учащиеся должны достичь процента завершения до этой даты и времени (по местному времени). Оставьте пустым, если срок не установлен.",
"threshold_label": "Минимальное завершение (%)",
"threshold_helper": "Для получения сертификата необходим совмещенный прогресс урока и упражнений (0–100). По умолчанию — 100%.",
"email_message_label": "Сертификационное письмо по электронной почте",
"email_message_helper": "Дополнительное сообщение, отображаемое в электронном письме, когда учащийся соответствует требованиям. Ссылка на страницу сертификата всегда включена.",
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!"
},
"certificates": {
"progress_hint": "Выполнено {current}% (для загрузки требуется {required}%)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Обновить",
"remove": "Удалить",
"custom_favicon": "Пользовательский значок",
"custom_code": "Пользовательский код"
"custom_code": "Пользовательский код",
"reconnect": "Переподключить домен",
"reconnect_hint": "Повторно подключите этот домен, чтобы создать новые записи настройки Cloudflare.",
"status_verified": "Проверено",
"status_pending_dns": "Ожидание DNS",
"status_pending_verification": "Ожидает проверки",
"status_reconnect_required": "Требуется переподключение",
"status_error": "Требует внимания",
"status_removed": "Удален",
"verified_description": "Этот личный домен активен и проверен.",
"pending_dns_description": "Добавьте записи DNS ниже, затем обновите проверку.",
"pending_verification_description": "DNS-записи были найдены. Выдача или проверка SSL все еще продолжается.",
"reconnect_description": "Этот домен необходимо снова подключить с помощью нового процесса настройки Cloudflare.",
"error_description": "Этот личный домен существует, но проверка еще не завершена."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Tạo bản dịch mới không thành công"
},
"exercise": {
"points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.",
"success": "Đã lưu thành công",
"submission_updated": "Đã cập nhật bài nộp vào",
"error_fetching": "Tìm nạp bài tập không thành công, vui lòng tải lại trang"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Chứng chỉ",
"certificate_settings": "Cài đặt chứng chỉ",
"tab_design": "Thiết kế",
"tab_settings": "Cài đặt",
"theme": "Chọn một chủ đề",
"logo": "Logo thương hiệu",
"to_update": "Để cập nhật hình ảnh thương hiệu của bạn, hãy truy cập",
@@ -1071,6 +1074,9 @@
"finish": "Kết thúc",
"next": "Tiếp theo",
"check": "Kiểm tra",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "bài nộp",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Liên kết"
},
"select_type": "Chọn loại",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Nâng cấp để sử dụng loại câu hỏi này"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Chứng chỉ",
"nav_landing_page": "Trang đích",
"nav_settings": "Cài đặt"
},
"completion": {
"modal": {
"title": "Yêu cầu khóa học đã hoàn tất!",
"subtitle": "Xin chúc mừng — bạn có thể xem và tải xuống chứng chỉ của mình bất kỳ lúc nào.",
"view_certificate": "Xem chứng chỉ",
"later": "Đóng"
}
},
"certification": {
"rules_heading": "Quy tắc chứng nhận",
"deadline_label": "Thời hạn chứng nhận",
"deadline_helper": "Học sinh phải đáp ứng tỷ lệ hoàn thành trước ngày này (giờ địa phương). Để trống không có thời hạn.",
"threshold_label": "Hoàn thành tối thiểu (%)",
"threshold_helper": "Cần có tiến độ kết hợp của bài học và bài tập để đạt được chứng chỉ (0–100). Mặc định là 100%.",
"email_message_label": "Thông báo email chứng nhận",
"email_message_helper": "Thông báo tùy chọn hiển thị trong email khi học sinh đáp ứng yêu cầu. Một liên kết đến trang chứng chỉ luôn được bao gồm.",
"email_message_placeholder": "ví dụ. Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!"
},
"certificates": {
"progress_hint": "Đã hoàn thành {current}% (cần có {required}% để tải xuống)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Làm mới",
"remove": "Xóa",
"custom_favicon": "Biểu tượng yêu thích tùy chỉnh",
"custom_code": "Mã tùy chỉnh"
"custom_code": "Mã tùy chỉnh",
"reconnect": "Kết nối lại miền",
"reconnect_hint": "Kết nối lại miền này để tạo bản ghi thiết lập Cloudflare mới.",
"status_verified": "Đã xác minh",
"status_pending_dns": "DNS đang chờ xử lý",
"status_pending_verification": "Đang chờ xác minh",
"status_reconnect_required": "Yêu cầu kết nối lại",
"status_error": "Cần chú ý",
"status_removed": "Đã xóa",
"verified_description": "Miền tùy chỉnh này đang hoạt động và đã được xác minh.",
"pending_dns_description": "Thêm bản ghi DNS bên dưới, sau đó làm mới xác minh.",
"pending_verification_description": "Bản ghi DNS đã được tìm thấy. Việc cấp hoặc xác thực SSL vẫn đang được tiến hành.",
"reconnect_description": "Miền này cần được kết nối lại thông qua quy trình thiết lập Cloudflare mới.",
"error_description": "Miền tùy chỉnh này tồn tại nhưng quá trình xác minh chưa hoàn tất."
}
},
"quiz": {
+30 -20
View File
@@ -1,33 +1,43 @@
<script>
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import HeartCrack from '@lucide/svelte/icons/heart-crack';
if (page.status == 404 && browser) {
goto('/404');
}
const isNotFound = $derived(page.status === 404);
// TODO: Add error tracking here
console.error('error message', page.error?.message);
</script>
<svelte:head>
<title>Something unexpected occured</title>
<title>{isNotFound ? 'Page not found' : 'Something unexpected occurred'}</title>
</svelte:head>
<Empty
title="Something unexpected occured."
description="Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and will push a fix ASAP. In the meantime, take a short break and come back a bit later."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="https://classroomio.com/tools" variant="secondary">Try Free tools</Button>
<Button href="/">Go Home</Button>
</div>
</Empty>
{#if isNotFound}
<Empty
title="Page not found"
description="The page you're looking for doesn't exist or you don't have permission to view it."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="/">Go Home</Button>
</div>
</Empty>
{:else}
<Empty
title="Something unexpected occurred."
description="Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and will push a fix ASAP. In the meantime, take a short break and come back a bit later."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="https://classroomio.com/tools" variant="secondary">Try Free tools</Button>
<Button href="/">Go Home</Button>
</div>
</Empty>
{/if}
@@ -1,14 +1,14 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import { redirect } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export const load = async ({ params, url }) => {
const slug = params?.slug ?? '';
const inviteToken = url.searchParams.get('invite_token') ?? null;
if (!slug) {
redirect(307, '/404');
throw error(404, 'Not found');
}
if (inviteToken) {
@@ -20,7 +20,7 @@ export const load = async ({ params, url }) => {
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
throw error(404, 'Not found');
}
const data = result.data;
@@ -50,9 +50,9 @@ export const load = async ({ params, url }) => {
requiresPaymentOrInvite: false,
token: inviteToken
};
} catch (error) {
console.error('Enroll load: failed to load invite', error);
redirect(307, '/404');
} catch (e) {
console.error('Enroll load: failed to load invite', e);
throw error(404, 'Not found');
}
}
@@ -63,7 +63,7 @@ export const load = async ({ params, url }) => {
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
throw error(404, 'Not found');
}
const courseData = result.data;
@@ -99,8 +99,8 @@ export const load = async ({ params, url }) => {
requiresPaymentOrInvite: !isFree,
token: null as string | null
};
} catch (error) {
console.error('Enroll load: failed to load course by slug', error);
redirect(307, '/404');
} catch (e) {
console.error('Enroll load: failed to load course by slug', e);
throw error(404, 'Not found');
}
};
@@ -1,7 +1,7 @@
import { getOrgBySiteName } from '$features/org/api/org.server';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export const load = async ({ params = { hash: '' } }) => {
try {
@@ -28,8 +28,8 @@ export const load = async ({ params = { hash: '' } }) => {
invite: result.data,
currentOrg
};
} catch (error) {
console.error('Error decoding organization invite params.hash', error);
redirect(307, '/404');
} catch (e) {
console.error('Error decoding organization invite params.hash', e);
throw error(404, 'Not found');
}
};