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:
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZDomainAction = z.enum(['connect', 'refresh', 'remove']);
|
||||
|
||||
export const ZDomainActionRequest = z.object({
|
||||
action: ZDomainAction,
|
||||
domain: z.string().min(1)
|
||||
});
|
||||
|
||||
export type TDomainAction = z.infer<typeof ZDomainAction>;
|
||||
export type TDomainActionRequest = z.infer<typeof ZDomainActionRequest>;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './audience';
|
||||
export * from './organization';
|
||||
export * from './domain';
|
||||
export * from './quiz';
|
||||
export * from './exercise';
|
||||
export * from './invite';
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# Cloudflare Custom Subdomain Reconnect PRD
|
||||
|
||||
## Status
|
||||
- Draft
|
||||
|
||||
## Date
|
||||
- March 22, 2026
|
||||
|
||||
## Purpose
|
||||
Replace the current Vercel-managed custom domain setup flow with a Cloudflare-managed custom subdomain flow, and require existing customers to reconnect their custom domains through the new setup path.
|
||||
|
||||
## Problem Statement
|
||||
Custom-domain setup is currently tightly coupled to Vercel project-domain APIs. The backend route in `apps/api/src/routes/domain/domain.ts` proxies directly to Vercel-specific actions, and the dashboard UI in `apps/dashboard/src/lib/features/settings/pages/domains.svelte` hardcodes Vercel DNS guidance (`cname.vercel-dns.com`).
|
||||
|
||||
This creates three problems:
|
||||
1. Domain provisioning logic is provider-specific instead of product-specific.
|
||||
2. The dashboard cannot render provider-agnostic DNS instructions or richer setup states.
|
||||
3. Existing setup assumes Vercel remains the control plane for custom domains, which no longer matches the intended infrastructure direction.
|
||||
|
||||
We are not doing an in-place live migration of existing Vercel-managed domains. Instead, organizations will be asked to set up their custom domain again through a new Cloudflare-backed flow.
|
||||
|
||||
## Confirmed Decisions
|
||||
1. Cloudflare will manage custom-domain setup going forward.
|
||||
2. The app itself will not be served by Cloudflare as a full-app hosting migration in this project.
|
||||
3. Scope is limited to custom subdomains only.
|
||||
4. Apex/root domains are out of scope.
|
||||
5. Existing customers will reconnect their domain through the new flow instead of being transparently migrated from Vercel.
|
||||
|
||||
## Goals
|
||||
1. Replace Vercel-specific custom-domain setup with a Cloudflare-backed setup flow.
|
||||
2. Keep the existing organization data model centered on `customDomain` and `isCustomDomainVerified`.
|
||||
3. Move domain orchestration into the backend so the dashboard does not manage provider sequencing itself.
|
||||
4. Render DNS instructions from API responses instead of hardcoded provider copy.
|
||||
5. Preserve runtime host-to-organization lookup for verified custom domains.
|
||||
6. Provide a clear reset and reconnect path for organizations with previously configured domains.
|
||||
|
||||
## Non-Goals
|
||||
- Supporting apex/root domains.
|
||||
- Running the main app on Cloudflare.
|
||||
- Preserving old Vercel domain attachments indefinitely.
|
||||
- Building a multi-provider long-term abstraction unless required for rollout safety.
|
||||
- Automatic migration of existing Vercel domain config without user action.
|
||||
|
||||
## Data Sources Checked
|
||||
- `apps/api/src/routes/domain/domain.ts`
|
||||
- `apps/api/src/services/org/domain.ts`
|
||||
- `apps/dashboard/src/lib/features/settings/pages/domains.svelte`
|
||||
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
|
||||
- `apps/dashboard/src/lib/utils/functions/domain.ts`
|
||||
- `apps/dashboard/src/lib/utils/services/org/domain.ts`
|
||||
- `apps/dashboard/src/lib/utils/types/org.ts`
|
||||
- `apps/dashboard/src/lib/features/app/layout-setup.ts`
|
||||
- `apps/dashboard/src/lib/features/org/api/org.server.ts`
|
||||
- `apps/docs/content/docs/how-to-guides/customize-organization.mdx`
|
||||
|
||||
## Current State
|
||||
|
||||
### Backend
|
||||
- `POST /domain` accepts `verify_domain`, `add_domain`, and `remove_domain`.
|
||||
- Those actions proxy directly to Vercel in `apps/api/src/services/org/domain.ts`.
|
||||
- The backend contract is provider-shaped rather than product-shaped.
|
||||
|
||||
### Dashboard
|
||||
- The settings page stores `customDomain` first via organization update, then separately calls the domain route.
|
||||
- DNS instructions are hardcoded to `CNAME -> cname.vercel-dns.com`.
|
||||
- Verification UX is reduced to a simple pending/verified state with no provider-neutral detail.
|
||||
|
||||
### Runtime
|
||||
- Runtime org resolution already supports `customDomain` lookups in `apps/dashboard/src/lib/features/app/layout-setup.ts`.
|
||||
- Server-side org fetchers already support `customDomain + isCustomDomainVerified`.
|
||||
- This means the main runtime routing model can stay mostly intact if Cloudflare forwards the original host correctly.
|
||||
|
||||
### Docs
|
||||
- User-facing docs still say external custom domains are “coming soon”, which no longer matches the in-app paid feature.
|
||||
|
||||
## User Story
|
||||
As an organization admin, I want to connect a branded subdomain like `courses.example.com` to ClassroomIO through a clear Cloudflare-backed flow, so learners can access my organization site on my own domain.
|
||||
|
||||
As an existing organization admin with a previously configured domain, I want to understand that I must reconnect the domain through the new setup flow, and I want the product to show me the exact DNS record I need to add.
|
||||
|
||||
## Functional Requirements
|
||||
1. Custom domain setup must accept only valid subdomains.
|
||||
2. The API must return normalized domain setup data instead of Vercel-specific payloads.
|
||||
3. The backend must own the full provisioning sequence:
|
||||
- validate input
|
||||
- provision/register domain with Cloudflare
|
||||
- persist organization domain state
|
||||
- return DNS instructions and current status
|
||||
4. The dashboard must render DNS instructions from API data.
|
||||
5. The dashboard must support at least these states:
|
||||
- no domain configured
|
||||
- reconnect required
|
||||
- pending DNS
|
||||
- pending verification
|
||||
- verified
|
||||
- error
|
||||
6. The dashboard must allow admins to remove a configured domain.
|
||||
7. Existing domains that were previously configured through Vercel must be markable as needing reconnection.
|
||||
8. Host-based org lookup must continue to work for verified custom domains.
|
||||
9. Free-plan gating for custom domains must remain unchanged unless a separate pricing decision changes it.
|
||||
|
||||
## Non-Functional Requirements
|
||||
1. Domain-related errors must be actionable and provider-neutral in UI copy.
|
||||
2. Domain setup operations must be idempotent enough for safe retries by admins.
|
||||
3. Partial state must be minimized; backend orchestration should avoid “saved in DB but not provisioned” behavior.
|
||||
4. Rollout must allow safe validation in staging before production reset/reconnect begins.
|
||||
|
||||
## Product Requirements
|
||||
|
||||
### 1) Setup Scope
|
||||
- Accept only subdomains such as `courses.example.com` or `www.example.com`.
|
||||
- Reject apex domains such as `example.com`.
|
||||
- Continue rejecting `*.classroomio.com` as a custom domain.
|
||||
|
||||
### 2) Reconnect Requirement
|
||||
- Existing organizations with `customDomain` set under the old system must not be treated as automatically healthy.
|
||||
- The product should clearly indicate that custom domains must be reconnected through the new Cloudflare flow.
|
||||
- A one-time reset/backfill step may mark existing domains as unverified or reconnect-required.
|
||||
|
||||
### 3) Backend-Owned Orchestration
|
||||
The system should move from this sequence:
|
||||
1. dashboard updates organization record
|
||||
2. dashboard calls domain provider API
|
||||
|
||||
To this sequence:
|
||||
1. dashboard submits desired domain
|
||||
2. backend validates and provisions the domain
|
||||
3. backend updates organization state
|
||||
4. backend returns DNS instructions and status
|
||||
|
||||
### 4) DNS Instruction Model
|
||||
The API should return a product-owned response shape, for example:
|
||||
- `status`
|
||||
- `verified`
|
||||
- `hostname`
|
||||
- `dnsRecords[]`
|
||||
- `message`
|
||||
- `reconnectRequired`
|
||||
|
||||
The dashboard must not hardcode provider DNS values.
|
||||
|
||||
### 5) Verification Model
|
||||
The system should support a refresh/check action that re-queries current provider state and updates organization verification status.
|
||||
|
||||
Minimum statuses:
|
||||
- `pending_dns`
|
||||
- `pending_verification`
|
||||
- `verified`
|
||||
- `error`
|
||||
|
||||
### 6) Remove Domain
|
||||
Removing a domain should:
|
||||
1. remove or deactivate provider-side setup where applicable
|
||||
2. clear organization `customDomain`
|
||||
3. clear verification state
|
||||
|
||||
## UX Requirements
|
||||
1. The settings page must clearly tell admins that only subdomains are supported.
|
||||
2. Existing domains that need reconnection must show a clear callout instead of silently appearing valid.
|
||||
3. DNS records must be copyable from the UI.
|
||||
4. Status badges and refresh actions must remain available after setup begins.
|
||||
5. User-facing copy must move through dashboard translations and not be hardcoded in component markup.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Recommended Direction
|
||||
Use a Cloudflare-backed service layer for custom-domain operations and expose a provider-neutral API contract to the dashboard.
|
||||
|
||||
### Backend Changes
|
||||
1. Replace Vercel-specific service functions in `apps/api/src/services/org/domain.ts`.
|
||||
2. Refactor `apps/api/src/routes/domain/domain.ts` so route responses are normalized around ClassroomIO product states, not Vercel response shapes.
|
||||
3. Move organization-state persistence into backend orchestration rather than split frontend/backend sequencing.
|
||||
4. Add any required Cloudflare env configuration in API config.
|
||||
|
||||
### Dashboard Changes
|
||||
1. Update `apps/dashboard/src/lib/features/settings/pages/domains.svelte` to consume normalized API responses.
|
||||
2. Remove hardcoded Vercel DNS values.
|
||||
3. Replace any Vercel-specific types in `apps/dashboard/src/lib/utils/types/org.ts`.
|
||||
4. Remove or refactor Vercel-specific helpers in `apps/dashboard/src/lib/utils/services/org/domain.ts`.
|
||||
5. Keep request typing in feature `utils/types.ts` consistent with existing dashboard conventions.
|
||||
|
||||
### Runtime Changes
|
||||
1. Keep `apps/dashboard/src/lib/features/app/layout-setup.ts` as the primary host-to-org resolver.
|
||||
2. Verify that custom domains routed through the new provider path still preserve host-based organization lookup.
|
||||
3. No large routing-model rewrite is expected in this project.
|
||||
|
||||
## Current State Audit
|
||||
|
||||
| Area | Current State | Gap | Priority |
|
||||
| --- | --- | --- | --- |
|
||||
| API domain setup | Vercel-specific proxy route | Needs Cloudflare-backed product contract | P0 |
|
||||
| Backend orchestration | Frontend updates org, then provisions domain | Partial-state risk | P0 |
|
||||
| Dashboard DNS instructions | Hardcoded Vercel CNAME target | Must become API-driven | P0 |
|
||||
| Verification states | Basic pending/verified only | Needs richer reconnect/pending/error model | P0 |
|
||||
| Existing domain rollout | Assumes current domain remains valid | Need reset + reconnect plan | P0 |
|
||||
| Runtime org lookup | Already supports verified custom domains | Needs validation only, not redesign | P1 |
|
||||
| Docs | External custom domains marked “coming soon” | Needs correction | P1 |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: API Contract + Cloudflare Service
|
||||
1. Replace Vercel-specific service implementation in `apps/api/src/services/org/domain.ts`.
|
||||
2. Redesign `POST /domain` response shape to be provider-neutral.
|
||||
3. Implement backend orchestration so setup and persistence happen together.
|
||||
4. Add backend validation to enforce subdomain-only custom domains.
|
||||
|
||||
### Phase 2: Dashboard Reconnect UX
|
||||
1. Update the domains settings page to render DNS instructions from API responses.
|
||||
2. Add reconnect-required state for existing domains.
|
||||
3. Update remove and refresh flows to use the new backend contract.
|
||||
4. Remove Vercel-specific helper/types that no longer apply.
|
||||
|
||||
### Phase 3: Existing Domain Reset
|
||||
1. Identify organizations with existing `customDomain` values.
|
||||
2. Decide reset behavior:
|
||||
- mark all as unverified
|
||||
- add reconnect-required marker in UI
|
||||
- optionally notify admins out-of-band
|
||||
3. Validate that old Vercel-managed domains no longer appear healthy in the product by default.
|
||||
|
||||
### Phase 4: Docs + Rollout
|
||||
1. Update docs to describe the Cloudflare-backed custom-domain flow.
|
||||
2. Align in-app copy with product behavior.
|
||||
3. Test in staging with at least one reconnect scenario and one fresh setup scenario.
|
||||
4. Roll out production reset/reconnect.
|
||||
|
||||
## Required File Touchpoints
|
||||
|
||||
### Core API
|
||||
- `apps/api/src/routes/domain/domain.ts`
|
||||
- `apps/api/src/services/org/domain.ts`
|
||||
- `apps/api/src/config/env.ts` or related provider config files
|
||||
- `apps/api/src/services/organization.ts` if orchestration is moved into org service layer
|
||||
|
||||
### Dashboard
|
||||
- `apps/dashboard/src/lib/features/settings/pages/domains.svelte`
|
||||
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
|
||||
- `apps/dashboard/src/lib/features/org/utils/types.ts`
|
||||
- `apps/dashboard/src/lib/utils/functions/domain.ts`
|
||||
- `apps/dashboard/src/lib/utils/services/org/domain.ts`
|
||||
- `apps/dashboard/src/lib/utils/types/org.ts`
|
||||
- `apps/dashboard/src/lib/utils/translations/en.json`
|
||||
|
||||
### Runtime Validation
|
||||
- `apps/dashboard/src/lib/features/app/layout-setup.ts`
|
||||
- `apps/dashboard/src/lib/features/org/api/org.server.ts`
|
||||
|
||||
### Docs
|
||||
- `apps/docs/content/docs/how-to-guides/customize-organization.mdx`
|
||||
|
||||
## Rollout Plan
|
||||
1. Build the Cloudflare-backed flow behind a feature flag if needed.
|
||||
2. Validate fresh setup and reconnect flows in staging.
|
||||
3. Reset existing production custom domains into reconnect-required state.
|
||||
4. Publish updated docs and product copy.
|
||||
5. Ask affected organizations to reconnect their domains.
|
||||
6. Remove stale Vercel-specific setup code after production rollout stabilizes.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
1. Domain input normalization and subdomain validation.
|
||||
2. Backend mapping from provider responses to product statuses.
|
||||
3. Reconnect-required state handling.
|
||||
|
||||
### Integration Tests
|
||||
1. Add domain returns DNS instructions and a pending status.
|
||||
2. Refresh/verify updates `isCustomDomainVerified` when provider verification succeeds.
|
||||
3. Remove domain clears organization state correctly.
|
||||
4. Verified custom domain still resolves organization context via host-based lookup.
|
||||
|
||||
### Manual Verification
|
||||
1. Fresh org connects a new subdomain successfully.
|
||||
2. Existing org with previously configured domain is shown reconnect-required state.
|
||||
3. DNS instructions shown in UI match backend response.
|
||||
4. Verified domain loads the correct organization site.
|
||||
5. Removing the domain clears status and UI state cleanly.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. The system no longer depends on Vercel project-domain APIs for custom-domain setup.
|
||||
2. Only subdomains are accepted for custom domains.
|
||||
3. The dashboard no longer hardcodes `cname.vercel-dns.com`.
|
||||
4. Existing domains can be explicitly reset into a reconnect-required flow.
|
||||
5. Verified custom domains continue to resolve to the correct organization site.
|
||||
6. Docs and in-app copy accurately describe the supported custom-domain flow.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Backend/provider mismatch causes false verification state | Broken domain UX | Normalize provider states and add integration coverage |
|
||||
| Existing domains appear valid after rollout even though reconnect is required | Admin confusion and broken trust | Reset state explicitly and surface reconnect-required UI |
|
||||
| Host forwarding differs from current assumptions | Verified domains fail to resolve org context | Validate host preservation in staging before rollout |
|
||||
| Frontend/backend sequencing remains split | Partial failures persist | Move orchestration into backend before release |
|
||||
| Docs lag behind rollout | Support burden | Update docs in same release |
|
||||
|
||||
## Open Questions
|
||||
1. Do we need a new persisted field for reconnect-required, or is resetting `isCustomDomainVerified=false` with derived UI state sufficient?
|
||||
2. Should production rollout notify affected organizations by email, in-app banner, or both?
|
||||
3. Do we want to keep the existing `/domain` endpoint shape and only change payloads, or introduce a clearer route contract now?
|
||||
Reference in New Issue
Block a user