diff --git a/core-web/apps/dotcms-ui/project.json b/core-web/apps/dotcms-ui/project.json
index 5c1a10c8c18e..b089578dee82 100644
--- a/core-web/apps/dotcms-ui/project.json
+++ b/core-web/apps/dotcms-ui/project.json
@@ -61,13 +61,20 @@
}
],
"styles": [
+ "node_modules/prismjs/themes/prism-okaidia.css",
"node_modules/primeicons/primeicons.css",
"libs/dotcms-scss/angular/styles.scss",
"node_modules/primeflex/primeflex.css",
"node_modules/primeng/resources/primeng.min.css",
"node_modules/gridstack/dist/gridstack.min.css"
],
- "scripts": [],
+ "scripts": [
+ "node_modules/prismjs/prism.js",
+ "node_modules/prismjs/components/prism-css.min.js",
+ "node_modules/prismjs/components/prism-typescript.min.js",
+ "node_modules/prismjs/components/prism-bash.min.js",
+ "node_modules/clipboard/dist/clipboard.min.js"
+ ],
"stylePreprocessorOptions": {
"includePaths": ["libs/dotcms-scss/angular"]
},
@@ -111,21 +118,31 @@
"outputPath": "../../tomcat9/webapps/ROOT/dotAdmin",
"baseHref": "",
"optimization": false,
- "sourceMap": true,
- "namedChunks": true,
+ "sourceMap": false,
+ "namedChunks": false,
"vendorChunk": true,
- "buildOptimizer": false
+ "buildOptimizer": false,
+ "extractLicenses": false,
+ "fileReplacements": [],
+ "progress": false,
+ "outputHashing": "none",
+ "commonChunk": true,
+ "budgets": [],
+ "externalDependencies": ["date-fns/locale"]
}
},
"defaultConfiguration": "production",
"dependsOn": ["^build"]
},
"serve": {
- "dependsOn": ["dotcms-webcomponents:build"],
+ "dependsOn": [],
"executor": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "apps/dotcms-ui/proxy-dev.conf.mjs",
- "buildTarget": "dotcms-ui:build:development"
+ "buildTarget": "dotcms-ui:build:development",
+ "hmr": true,
+ "liveReload": false,
+ "watch": true
}
},
"extract-i18n": {
diff --git a/core-web/apps/dotcms-ui/src/app/app-routing.module.ts b/core-web/apps/dotcms-ui/src/app/app-routing.module.ts
index b68fdd8c8685..b17ab990d042 100644
--- a/core-web/apps/dotcms-ui/src/app/app-routing.module.ts
+++ b/core-web/apps/dotcms-ui/src/app/app-routing.module.ts
@@ -115,7 +115,7 @@ const PORTLETS_ANGULAR: Route[] = [
{
path: 'starter',
loadChildren: () =>
- import('@portlets/dot-starter/dot-starter.module').then((m) => m.DotStarterModule)
+ import('@portlets/dot-starter/dot-starter.routes').then((m) => m.dotStarterRoutes)
},
{
canActivate: [MenuGuardService],
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html
new file mode 100644
index 000000000000..ab51396ade01
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.html
@@ -0,0 +1,285 @@
+@if (userData$ | async; as user) {
+
+
+
+ Not a marketer?
+
+
+
+
+
+
+
+
+ {{ 'starter.side.title' | dm }}
+
+
+
+
+
+
+
+
+
+
+ {{ 'starter.side.resources.title' | dm }}
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss
new file mode 100644
index 000000000000..7cb0967d6bf2
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.scss
@@ -0,0 +1,246 @@
+@use "variables" as *;
+
+$number-link-icon-size: 2.28rem;
+
+:host {
+ display: block;
+ font-size: $font-size-md;
+ height: 100%;
+ overflow: auto;
+ background-color: $white;
+
+ hr {
+ border-top: 1px solid $color-palette-gray-300;
+ }
+
+ a {
+ text-decoration: none;
+ }
+
+ .dot-starter-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ p-checkbox {
+ margin-top: $spacing-5;
+ }
+ }
+
+ .dot-starter-title {
+ color: $black;
+ font-size: $font-size-lg;
+ margin: $spacing-5 $spacing-3 $spacing-1;
+ }
+
+ .dot-starter-description {
+ color: $black;
+ margin: 0 $spacing-3;
+ }
+
+ .dot-starter-top-content,
+ .dot-starter-bottom-content {
+ display: flex;
+ margin: 0 auto;
+ max-width: 77.42rem;
+ flex-grow: 1;
+ }
+
+ .dot-starter-top-main__section {
+ background-color: $white;
+ counter-reset: css-counter 0;
+ flex-grow: 1;
+ margin-left: 0;
+ margin-bottom: $spacing-4;
+ margin-right: $spacing-1;
+ display: flex;
+ }
+
+ .dot-starter-offset {
+ margin-left: $spacing-5;
+ }
+
+ .dot-starter-top-main__block {
+ border-radius: $border-radius-md;
+ box-shadow: $shadow-l;
+
+ align-content: space-between;
+ border-bottom: 1px solid $color-palette-gray-300;
+ display: flex;
+ flex-direction: column;
+ margin: $spacing-3;
+ padding: $spacing-5 $spacing-3;
+
+ transition: background-color $basic-speed ease-in;
+ width: 25%;
+
+ &:hover {
+ background-color: $bg-hover;
+ }
+ }
+
+ .dot-starter-top-main__link-number {
+ align-self: center;
+ margin: 0 $spacing-4 $spacing-1;
+ text-align: center;
+
+ span {
+ align-items: center;
+ color: $color-palette-primary;
+ counter-increment: css-counter 1;
+ display: inline-flex;
+ font-size: $md-icon-size-extra-big;
+ height: $number-link-icon-size;
+ justify-content: center;
+ width: $number-link-icon-size;
+
+ &::before {
+ content: counter(css-counter);
+ }
+ }
+ }
+
+ .dot-starter-top-main__link-data {
+ align-self: center;
+ flex-grow: 1;
+ text-align: center;
+
+ h4 {
+ color: $black;
+ font-size: $font-size-lmd;
+ font-weight: $font-weight-semi-bold;
+ margin: 0;
+ }
+ p {
+ color: $color-palette-gray-700;
+ margin: 0;
+ font-size: $font-size-md;
+ }
+ }
+
+ .dot-starter-top-main__link-arrow {
+ align-self: center;
+
+ i {
+ color: $color-palette-gray-500;
+ font-size: $font-size-lg;
+ margin-right: $spacing-3;
+ }
+ }
+
+ .dot-starter-top-main__title {
+ font-size: $font-size-lmd;
+ font-weight: $font-weight-semi-bold;
+ margin: 0;
+ border-bottom: 1px solid $color-palette-gray-300;
+ padding-bottom: $spacing-3;
+ }
+ .dot-starter-top-secondary__block {
+ display: flex;
+ padding-bottom: $spacing-8;
+
+ &:last-child {
+ padding-bottom: 0;
+ }
+ }
+
+ .dot-starter-top-secondary__link-icon {
+ padding-top: 0;
+
+ span {
+ color: $color-palette-primary;
+ fill: $color-palette-primary;
+ display: inline-flex;
+ width: $number-link-icon-size;
+ }
+
+ svg {
+ fill: $color-palette-primary;
+ height: 1.71rem;
+ width: $md-icon-size-normal;
+ }
+ }
+
+ .dot-starter-top-secondary__link-data {
+ padding: 0;
+
+ h4 {
+ color: $black;
+ font-size: $font-size-lmd;
+ margin: 0;
+ }
+ p {
+ color: $color-palette-gray-700;
+ margin: 0;
+ }
+ }
+
+ .dot-starter-top-secondary__link-data-apis {
+ padding: 0;
+
+ h4 {
+ color: $color-palette-primary;
+ font-size: $font-size-lmd;
+ font-weight: 500;
+ margin: 0;
+ padding-bottom: $spacing-1;
+ }
+ p {
+ color: $color-palette-gray-700;
+ margin: 0;
+ }
+ }
+
+ .dot-starter-bottom-content {
+ flex-wrap: wrap;
+ padding: $spacing-1 0 $spacing-5 $spacing-5;
+ }
+
+ .dot-starter-bottom__block {
+ background-color: $white;
+ border-bottom: 1px solid $color-palette-gray-300;
+ box-shadow: $shadow-m;
+ display: flex;
+ flex-grow: 1;
+ height: 100%;
+ overflow: hidden;
+ transition: background-color $basic-speed ease-in;
+ border-radius: $border-radius-md;
+ gap: $spacing-1;
+ padding: $spacing-3;
+
+ &:hover {
+ background-color: $bg-hover;
+ }
+ }
+
+ .dot-starter-bottom__link-icon {
+ padding-top: $spacing-0;
+ span {
+ color: $color-palette-primary;
+ display: inline-flex;
+ }
+ }
+
+ .dot-starter-bottom__link-data {
+ margin-right: $spacing-5;
+ padding: 0;
+
+ h4 {
+ color: $black;
+ font-size: $font-size-lmd;
+ margin: 0;
+ }
+ p {
+ color: $color-palette-gray-700;
+ margin: 0;
+ }
+ }
+}
+.button-link {
+ cursor: pointer;
+ color: #515667;
+ position: absolute;
+ top: 20px;
+ right: 20px;
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts
new file mode 100644
index 000000000000..209da16d15a7
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.spec.ts
@@ -0,0 +1,325 @@
+import { createComponentFactory, Spectator, byTestId, mockProvider } from '@ngneat/spectator/jest';
+import { of } from 'rxjs';
+
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
+import { ActivatedRoute } from '@angular/router';
+
+import { CheckboxModule, Checkbox } from 'primeng/checkbox';
+
+import { DotMessageService, DotRouterService } from '@dotcms/data-access';
+import { CoreWebService } from '@dotcms/dotcms-js';
+import { DotMessagePipe } from '@dotcms/ui';
+import {
+ CoreWebServiceMock,
+ MockDotMessageService,
+ MockDotRouterService
+} from '@dotcms/utils-testing';
+
+import { DotStarterResolver } from './dot-starter-resolver.service';
+import { DotStarterComponent } from './dot-starter.component';
+
+import { DotAccountService } from '../../api/services/dot-account-service';
+
+const messages = {
+ 'starter.title': 'Welcome!',
+ 'starter.description':
+ 'You are logged in as {0}. To help you get started building with dotCMS we provided some quick links.',
+ 'starter.dont.show': `Don't show this again`,
+ 'starter.main.link.data.model.title': 'Create data model',
+ 'starter.main.link.data.model.description': 'Create data model description',
+ 'starter.main.link.add.content.title': 'Add content',
+ 'starter.main.link.add.content.description': 'Add content description',
+ 'starter.main.link.design.layout.title': 'Design a layout',
+ 'starter.main.link.design.layout.description': 'Design a layout description',
+ 'starter.main.link.create.page.title': 'Create a page',
+ 'starter.main.link.create.page.description': 'Create a page description',
+ 'starter.side.title': 'APIs and Services',
+ 'starter.side.link.graphQl.title': 'GraphQL API',
+ 'starter.side.link.graphQl.description': 'GraphQL API description',
+ 'starter.side.link.content.title': 'Content API',
+ 'starter.side.link.content.description': 'Content API description',
+ 'starter.side.link.image.processing.title': 'Image Resizing and Processing',
+ 'starter.side.link.image.processing.description': 'Image Resizing and Processing description',
+ 'starter.side.link.page.layout.title': 'Page Layout API (Layout as a Service)',
+ 'starter.side.link.page.layout.description': 'Page Layout API description',
+ 'starter.side.link.generate.key.title': 'Generate API Token',
+ 'starter.side.link.generate.key.description': 'Generate API Token description',
+ 'starter.footer.link.documentation.title': 'Documentation',
+ 'starter.footer.link.documentation.description': 'Documentation description',
+ 'starter.footer.link.examples.title': 'Examples',
+ 'starter.footer.link.examples.description': 'Examples description',
+ 'starter.footer.link.community.title': 'Community',
+ 'starter.footer.link.community.description': 'Community description',
+ 'starter.footer.link.training.title': 'Training Videos',
+ 'starter.footer.link.training.description': 'Training Videos description',
+ 'starter.footer.link.review.title': 'Write A Review',
+ 'starter.footer.link.review.description': 'Write A Review description',
+ 'starter.footer.link.feedback.title': 'Feedback',
+ 'starter.footer.link.feedback.description': 'Feedback description'
+};
+
+const routeDataMock = {
+ userData: {
+ user: {
+ email: 'admin@dotcms.com',
+ givenName: 'Admin',
+ roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3',
+ surname: 'User',
+ userId: 'testId'
+ },
+ permissions: {
+ STRUCTURES: { canRead: true, canWrite: true },
+ HTMLPAGES: { canRead: true, canWrite: true },
+ TEMPLATES: { canRead: true, canWrite: true },
+ CONTENTLETS: { canRead: true, canWrite: true }
+ }
+ }
+};
+
+const routeDataWithoutPermissionsMock = {
+ userData: {
+ user: {
+ email: 'admin@dotcms.com',
+ givenName: 'Admin',
+ roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3',
+ surname: 'User',
+ userId: 'testId'
+ },
+ permissions: {
+ STRUCTURES: { canRead: true, canWrite: false },
+ HTMLPAGES: { canRead: true, canWrite: false },
+ TEMPLATES: { canRead: true, canWrite: false },
+ CONTENTLETS: { canRead: true, canWrite: false }
+ }
+ }
+};
+
+class ActivatedRouteMock {
+ get data() {
+ return of(routeDataMock);
+ }
+}
+
+describe('DotStarterComponent', () => {
+ describe('With user permissions', () => {
+ let spectator: Spectator;
+ const messageServiceMock = new MockDotMessageService(messages);
+
+ const createComponent = createComponentFactory({
+ component: DotStarterComponent,
+ imports: [DotMessagePipe, CheckboxModule],
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: DotMessageService, useValue: messageServiceMock },
+ { provide: ActivatedRoute, useClass: ActivatedRouteMock },
+ { provide: CoreWebService, useClass: CoreWebServiceMock },
+ { provide: DotRouterService, useClass: MockDotRouterService },
+ DotStarterResolver,
+ mockProvider(DotAccountService, {
+ addStarterPage: jest.fn().mockReturnValue(of(true)),
+ removeStarterPage: jest.fn().mockReturnValue(of(true))
+ })
+ ]
+ });
+
+ beforeEach(() => {
+ spectator = createComponent();
+ });
+
+ describe('With user permissions', () => {
+ it('should set proper labels to the main container', () => {
+ expect(spectator.query('.dot-starter-description')).toHaveText(
+ 'You are logged in as Admin. To help you get started building with dotCMS we provided some quick links.'
+ );
+
+ expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText(
+ messageServiceMock.get('starter.main.link.data.model.title')
+ );
+ expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveText(
+ messageServiceMock.get('starter.main.link.data.model.description')
+ );
+
+ expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText(
+ messageServiceMock.get('starter.main.link.add.content.title')
+ );
+ expect(spectator.query(byTestId('starter.main.link.content'))).toHaveText(
+ messageServiceMock.get('starter.main.link.add.content.description')
+ );
+
+ expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText(
+ messageServiceMock.get('starter.main.link.design.layout.title')
+ );
+ expect(spectator.query(byTestId('starter.main.link.design.layout'))).toHaveText(
+ messageServiceMock.get('starter.main.link.design.layout.description')
+ );
+
+ expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText(
+ messageServiceMock.get('starter.main.link.create.page.title')
+ );
+ expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveText(
+ messageServiceMock.get('starter.main.link.create.page.description')
+ );
+ });
+
+ it('should set proper labels to the side container', () => {
+ expect(spectator.query(byTestId('dot-side-title'))).toHaveText(
+ messageServiceMock.get('starter.side.title')
+ );
+
+ expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText(
+ messageServiceMock.get('starter.side.link.graphQl.title')
+ );
+ expect(spectator.query(byTestId('starter.side.link.graphQl'))).toHaveText(
+ messageServiceMock.get('starter.side.link.graphQl.description')
+ );
+
+ expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText(
+ messageServiceMock.get('starter.side.link.content.title')
+ );
+ expect(spectator.query(byTestId('starter.side.link.content'))).toHaveText(
+ messageServiceMock.get('starter.side.link.content.description')
+ );
+
+ expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText(
+ messageServiceMock.get('starter.side.link.image.processing.title')
+ );
+ expect(spectator.query(byTestId('starter.side.link.image.processing'))).toHaveText(
+ messageServiceMock.get('starter.side.link.image.processing.description')
+ );
+
+ expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText(
+ messageServiceMock.get('starter.side.link.page.layout.title')
+ );
+ expect(spectator.query(byTestId('starter.side.link.page.layout'))).toHaveText(
+ messageServiceMock.get('starter.side.link.page.layout.description')
+ );
+
+ expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText(
+ messageServiceMock.get('starter.side.link.generate.key.title')
+ );
+ expect(spectator.query(byTestId('starter.side.link.generate.key'))).toHaveText(
+ messageServiceMock.get('starter.side.link.generate.key.description')
+ );
+ });
+
+ it('should set proper labels to the footer container', () => {
+ expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.documentation.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.documentation'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.documentation.description')
+ );
+
+ expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.examples.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.examples'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.examples.description')
+ );
+
+ expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.community.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.community'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.community.description')
+ );
+
+ expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.training.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.training'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.training.description')
+ );
+
+ expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.review.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.review'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.review.description')
+ );
+
+ expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.feedback.title')
+ );
+ expect(spectator.query(byTestId('starter.footer.link.feedback'))).toHaveText(
+ messageServiceMock.get('starter.footer.link.feedback.description')
+ );
+ });
+
+ it('should have right links to internal portlets', () => {
+ expect(spectator.query(byTestId('starter.main.link.data.model'))).toHaveAttribute(
+ 'routerLink',
+ '/content-types-angular/create/content'
+ );
+
+ expect(spectator.query(byTestId('starter.main.link.content'))).toHaveAttribute(
+ 'routerLink',
+ '/c/content/new/webPageContent'
+ );
+
+ expect(
+ spectator.query(byTestId('starter.main.link.design.layout'))
+ ).toHaveAttribute('routerLink', '/templates/new/designer');
+
+ expect(spectator.query(byTestId('starter.main.link.create.page'))).toHaveAttribute(
+ 'routerLink',
+ '/c/content/new/htmlpageasset'
+ );
+ });
+
+ it('should call the endpoint to hide/show the portlet', () => {
+ const dotAccountService = spectator.inject(DotAccountService);
+ const checkbox = spectator.query(Checkbox);
+
+ expect(checkbox.label).toBe(messageServiceMock.get('starter.dont.show'));
+
+ spectator.component.handleVisibility(true);
+ expect(dotAccountService.removeStarterPage).toHaveBeenCalledTimes(1);
+
+ spectator.component.handleVisibility(false);
+ expect(dotAccountService.addStarterPage).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('Without user permissions', () => {
+ let spectator: Spectator;
+ const messageServiceMock = new MockDotMessageService(messages);
+
+ const createComponent = createComponentFactory({
+ component: DotStarterComponent,
+ imports: [DotMessagePipe, CheckboxModule],
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: DotMessageService, useValue: messageServiceMock },
+ {
+ provide: ActivatedRoute,
+ useValue: { data: of(routeDataWithoutPermissionsMock) }
+ },
+ { provide: CoreWebService, useClass: CoreWebServiceMock },
+ { provide: DotRouterService, useClass: MockDotRouterService },
+ DotStarterResolver,
+ mockProvider(DotAccountService, {
+ addStarterPage: jest.fn().mockReturnValue(of(true)),
+ removeStarterPage: jest.fn().mockReturnValue(of(true))
+ })
+ ]
+ });
+
+ beforeEach(() => {
+ spectator = createComponent();
+ });
+
+ it('should hide links from the main container', () => {
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('starter.main.link.data.model'))).toBeFalsy();
+ expect(spectator.query(byTestId('starter.main.link.content'))).toBeFalsy();
+ expect(spectator.query(byTestId('starter.main.link.design.layout'))).toBeFalsy();
+ expect(spectator.query(byTestId('starter.main.link.create.page'))).toBeFalsy();
+ });
+ });
+});
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts
new file mode 100644
index 000000000000..bb7cf834858e
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-author/onboarding-author.component.ts
@@ -0,0 +1,113 @@
+import { Observable } from 'rxjs';
+
+import { AsyncPipe } from '@angular/common';
+import { Component, DestroyRef, EventEmitter, inject, OnInit, Output } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { RouterLink } from '@angular/router';
+
+import { CheckboxModule } from 'primeng/checkbox';
+
+import { map, mergeMap } from 'rxjs/operators';
+
+import { DotCurrentUserService } from '@dotcms/data-access';
+import {
+ DotCurrentUser,
+ DotPermissionsType,
+ PermissionsType,
+ UserPermissions
+} from '@dotcms/dotcms-models';
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { DotAccountService } from '../../../../api/services/dot-account-service';
+
+@Component({
+ selector: 'dot-onboarding-author',
+ templateUrl: './onboarding-author.component.html',
+ styleUrls: ['./onboarding-author.component.scss'],
+ providers: [DotAccountService],
+ standalone: true,
+ imports: [AsyncPipe, DotMessagePipe, CheckboxModule, RouterLink]
+})
+export class DotOnboardingAuthorComponent implements OnInit {
+ private dotAccountService = inject(DotAccountService);
+ @Output() eventEmitter = new EventEmitter<'reset-user-profile'>();
+
+ userData$: Observable<{
+ username: string;
+ showCreateContentLink: boolean;
+ showCreateDataModelLink: boolean;
+ showCreatePageLink: boolean;
+ showCreateTemplateLink: boolean;
+ }>;
+ username: string;
+ showCreateContentLink: boolean;
+ showCreateDataModelLink: boolean;
+ showCreatePageLink: boolean;
+ showCreateTemplateLink: boolean;
+
+ readonly #destroyRef = inject(DestroyRef);
+
+ private dotCurrentUserService = inject(DotCurrentUserService);
+
+ ngOnInit() {
+ this.userData$ = this.dotCurrentUserService.getCurrentUser().pipe(
+ mergeMap((user: DotCurrentUser) => {
+ return this.dotCurrentUserService
+ .getUserPermissions(
+ user.userId,
+ [UserPermissions.WRITE],
+ [
+ PermissionsType.HTMLPAGES,
+ PermissionsType.STRUCTURES,
+ PermissionsType.TEMPLATES,
+ PermissionsType.CONTENTLETS
+ ]
+ )
+ .pipe(
+ map((permissionsType: DotPermissionsType) => {
+ return { user, permissions: permissionsType };
+ }),
+ map(
+ ({
+ user,
+ permissions
+ }: {
+ user: DotCurrentUser;
+ permissions: DotPermissionsType;
+ }) => {
+ return {
+ username: user.givenName,
+ showCreateContentLink:
+ permissions[PermissionsType.CONTENTLETS].canWrite,
+ showCreateDataModelLink:
+ permissions[PermissionsType.STRUCTURES].canWrite,
+ showCreatePageLink:
+ permissions[PermissionsType.HTMLPAGES].canWrite,
+ showCreateTemplateLink:
+ permissions[PermissionsType.TEMPLATES].canWrite
+ };
+ }
+ )
+ );
+ })
+ );
+ }
+
+ /**
+ * Hit the endpoint to show/hide the tool group in the menu.
+ * @param {boolean} hide
+ * @memberof DotStarterComponent
+ */
+ handleVisibility(hide: boolean): void {
+ const subscription = hide
+ ? this.dotAccountService.removeStarterPage()
+ : this.dotAccountService.addStarterPage();
+
+ subscription.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe();
+ }
+
+ public resetUserProfile(): void {
+ localStorage.removeItem('user_profile');
+ this.eventEmitter.emit('reset-user-profile');
+ }
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts
new file mode 100644
index 000000000000..d962b73b9f2f
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/content.ts
@@ -0,0 +1,353 @@
+import { OnboardingContent, SupportedFrameworks } from './models';
+
+export const STORAGE_KEY = 'dotcmsDeveloperOnboarding';
+
+export const getOnboardingContent = (selectedFramework: SupportedFrameworks): OnboardingContent => {
+ return {
+ title: 'Start with dotCMS headless development in minutes',
+ description: '',
+ steps: [
+ {
+ number: 1,
+ title: 'Bootstrap the project using the cli',
+ description:
+ 'Use the @dotcms/create-app CLI to scaffold a production-ready project that serves as the foundation for your headless application.',
+ substeps: [
+ {
+ code:
+ 'npx @dotcms/create-app@latest my-dotcms-app ' +
+ ' --framework=' +
+ selectedFramework +
+ ' --directory=' +
+ ' --url=' +
+ ' --username= ' +
+ ' --password=',
+ language: 'bash',
+ type: 'terminal',
+ explanation: {
+ title: 'Create the project using create-dotcms-app cli',
+ description: `
+ The CLI connects to your dotCMS cloud instance ,sets up the UVE config, gets **Authentication Token** and **Site ID** using the below options:
+
+ \n- **--framework** : Specifies the frontend framework to use.
+ \n- **--directory** : Sets the project directory.
+ \n- **--url**: dotCMS Cloud instance URL.
+ \n- **--username**: dotCMS Cloud instance username.
+ \n- **--password**: dotCMS Cloud instance password.
+
+ \n⚠️ **Save the generated variables — once the terminal is cleared they are lost. Use --help for more options.**
+ `
+ }
+ },
+ {
+ code: '',
+ language: 'bash',
+ type: 'terminal',
+ explanation: {
+ title: 'Follow the instructions generated by the CLI to complete the setup.',
+ description:
+ 'Once the cli scaffolds the project ,follow the CLI generated instructions to set env variables, install dependencies, and run the project.'
+ }
+ }
+ // {
+ // code: 'cp .env.example .env',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Replace the environment variables',
+ // description: `Replace the generated project’s environment variables with the CLI-provided values. Following variables are required to connect your frontend to dotCMS :
+ // \n- Authentication token
+ // \n- Site identifier
+ // \n- Cloud URL
+ // `
+ // }
+ // },
+ // {
+ // code: 'npm install',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Install the dependencies',
+ // description: `Install the dependencies in the project.`
+ // }
+ // },
+ // {
+ // code: 'npm run dev',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Start the development server',
+ // description: `The development server will start and the app will be available at http://localhost:${portFrontEnd}. Keep this terminal running while developing.`
+ // }
+ // }
+ ]
+ },
+ // {
+ // number: 2,
+ // title: 'Install dotCMS libraries',
+ // description:
+ // 'Install the official dotCMS SDK packages that enable your Next.js app to communicate with dotCMS and render content with full TypeScript support.',
+ // substeps: [
+ // {
+ // code: 'npm install @dotcms/client @dotcms/react @dotcms/types',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Install dotCMS packages',
+ // description: `- \`@dotcms/client\` - Handles authentication, API communication, and content fetching from dotCMS
+ // - \`@dotcms/react\` - Provides React-specific hooks and components for rendering dotCMS content
+ // - \`@dotcms/types\` - Provides TypeScript type definitions for dotCMS SDK`
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // number: 3,
+ // title: 'Authenticate dotCMS (create your API Key)',
+ // description:
+ // 'Set up secure authentication between your Next.js app and dotCMS using a read-only API key and environment variables.',
+ // substeps: [
+ // {
+ // code: 'touch .env.local',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Create .env.local file in project root',
+ // description: `To generate your API key:
+ // - Navigate to [**System** → **Users**](https://minstarter.dotcms.com/c/users) in your dotCMS instance
+ // - Select your user account (e.g., \`admin@dotcms.com\`)
+ // - Scroll to the **API Access Key** section
+ // - Click **Generate** to create a new key (read-only permissions recommended)
+ // - Copy the generated key - it will look something like: \`abcd1234efgh5678ijkl9012mnop3456\`
+ //
+ // **Important:** Save this key safely - you'll need it in the next step!
+ //
+ // For detailed instructions, refer to: [dotCMS REST API Authentication](https://dev.dotcms.com/docs/rest-api-authentication#ReadOnlyToken)`
+ // }
+ // },
+ // {
+ // code: `NEXT_PUBLIC_DOTCMS_URL=https://minstarter.dotcms.com
+ // DOTCMS_TOKEN=your-api-key-here`,
+ // language: 'env',
+ // type: 'file',
+ // filePath: '.env.local',
+ // explanation: {
+ // title: 'Add your dotCMS credentials',
+ // description: `Replace \`your-api-key-here\` with the actual API Key you copied from dotCMS.
+ //
+ // - \`NEXT_PUBLIC_DOTCMS_URL\` - Available in both server and client components (needed for image URLs)
+ //
+ // - \`DOTCMS_TOKEN\` - Server-only (keeps your API key secure, never exposed to browser)`
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // number: 4,
+ // title: 'Fetch content from dotCMS',
+ // description:
+ // 'Test your dotCMS connection by fetching page data and displaying it as JSON. Seeing the raw data helps you understand the structure of dotCMS content before rendering it visually.',
+ // substeps: [
+ // {
+ // code: `import { createDotCMSClient } from '@dotcms/client';
+ //
+ // // Create dotCMS client
+ // const client = createDotCMSClient({
+ // dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_URL!,
+ // authToken: process.env.DOTCMS_TOKEN,
+ // });
+ //
+ // export default async function Home() {
+ // // Fetch page content from dotCMS
+ // const { pageAsset } = await client.page.get('/');
+ //
+ // return (
+ //
+ //
dotCMS Page Data
+ //
+ // {JSON.stringify(pageAsset, null, 2)}
+ //
+ //
+ // );
+ // }`,
+ // language: 'typescript',
+ // type: 'file',
+ // filePath: 'src/app/page.tsx',
+ // explanation: {
+ // title: 'Replace all content in the page component',
+ // description: `- \`createDotCMSClient\` - Creates a configured client instance that can communicate with your dotCMS instance
+ // - \`client.page.get('/')\` - Makes an API call to fetch the home page content (identified by path \`/\`)
+ // - \`pageAsset\` - The complete page object containing all content, layout, and configuration data
+ // - \`JSON.stringify\` - Converts the JavaScript object into readable JSON format for inspection`
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // number: 5,
+ // title: 'Render content with DotCMSLayoutBody',
+ // description:
+ // 'Transform the raw JSON data into a beautiful, interactive page. You will create a client-side component that automatically maps dotCMS content types to React components.',
+ // substeps: [
+ // {
+ // code: 'mkdir -p src/components && touch src/components/DotCMSPageClient.tsx',
+ // language: 'bash',
+ // type: 'terminal',
+ // explanation: {
+ // title: 'Create components directory',
+ // description: `We need a dedicated place for our client-side components.`
+ // }
+ // },
+ // {
+ // code: `'use client';
+ //
+ // import { DotCMSLayoutBody } from '@dotcms/react';
+ // import type { DotCMSPageAsset } from '@dotcms/types';
+ // import Image from 'next/image';
+ //
+ // // 1. Define the Banner Component
+ // // This component matches the fields defined in your dotCMS "Banner" Content Type
+ // function Banner({ title, caption, image, link, target }: any) {
+ // const dotcmsUrl = process.env.NEXT_PUBLIC_DOTCMS_URL;
+ // const imageUrl = image?.idPath ? \`\${dotcmsUrl}\${image.idPath}\` : null;
+ //
+ // return (
+ //
+ //
+ //
+ // {title &&
{title}
}
+ // {caption &&
{caption}
}
+ // {link && (
+ //
+ // Learn More
+ //
+ // )}
+ //
+ //
+ // {imageUrl && (
+ //
+ // {/* 'unoptimized' allows loading images from external dotCMS domains without extra Next.js config */}
+ //
+ //
+ // )}
+ //
+ // );
+ // }
+ //
+ // // 2. Create the Component Map
+ // // Keys must EXACTLY match the Content Type Variable Name in dotCMS
+ // const COMPONENTS_MAP = {
+ // Banner: Banner,
+ // };
+ //
+ // // 3. Main Client Component
+ // export function DotCMSPageClient({ pageAsset }: { pageAsset: DotCMSPageAsset }) {
+ // return (
+ //
+ // );
+ // }`,
+ // language: 'typescript',
+ // type: 'file',
+ // filePath: 'src/components/DotCMSPageClient.tsx',
+ // explanation: {
+ // title: 'Create the Component Mapper',
+ // description: `This file handles the logic of turning data into UI:
+ //
+ // - **Banner Component**: A standard React component. Note the \`unoptimized\` prop on the Image—this allows Next.js to display images from your specific dotCMS instance URL without complex configuration changes.
+ // - **COMPONENTS_MAP**: This object tells the SDK "When you see content of type 'Banner', render this React component."
+ // - **DotCMSLayoutBody**: The magic component from the SDK that iterates over the page layout and renders the correct components automatically.`
+ // }
+ // },
+ // {
+ // code: `import { createDotCMSClient } from '@dotcms/client';
+ // import { DotCMSPageClient } from '@/components/DotCMSPageClient';
+ //
+ // const client = createDotCMSClient({
+ // dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_URL!,
+ // authToken: process.env.DOTCMS_TOKEN,
+ // });
+ //
+ // export default async function Home() {
+ // const { pageAsset } = await client.page.get('/');
+ //
+ // return (
+ //
+ //
+ //
+ // );
+ // }`,
+ // language: 'typescript',
+ // type: 'file',
+ // filePath: 'src/app/page.tsx',
+ // explanation: {
+ // title: 'Update the Home Page',
+ // description: `We replace the raw JSON dump with our new \`\`.
+ //
+ // The server fetches the data securely, and the client component renders it. This "Hybrid" approach gives you the best of both worlds: SEO performance and interactive UI.`
+ // }
+ // }
+ // ]
+ // },
+ // {
+ // number: 6,
+ // title: 'Configure Universal Visual Editor',
+ // description:
+ // 'Now connect the two worlds. We need to tell dotCMS to load your local development environment (`localhost:3000`) inside its visual editor instead of the production site.',
+ // substeps: [
+ // {
+ // code: `{
+ // "config": [
+ // {
+ // "pattern": ".*",
+ // "url": "http://localhost:3000"
+ // }
+ // ]
+ // }`, // No code needed, purely visual
+ // language: 'json',
+ // type: 'config', // UI placeholder
+ // explanation: {
+ // title: 'Open the Visual Editor',
+ // description: `1. In dotCMS, navigate to **Settings > Apps** and click on **UVE - Universal Visual Editor**
+ // 2. Click the plus button at the right of the site we're integrating (i.e., \`demo.dotcms.com\`).
+ // 3. In the Configuration field, add the JSON object.`
+ // }
+ // }
+ // ]
+ // },
+ {
+ number: 2,
+ title: 'Edit your page visually',
+ description:
+ 'The moment of truth. You will now edit content in dotCMS and see it update live in your Next.js application without touching the code.',
+ substeps: [
+ {
+ code: '', // No code needed, purely visual
+ language: 'text',
+ type: 'terminal',
+ explanation: {
+ title: 'Open the Visual Editor',
+ description: `1. Go to **Site Browser** → **Pages** in the dotCMS sidebar.
+2. Click on the **Home** page (index).
+3. The screen will split: dotCMS controls on the right, your Next.js app on the left.
+4. Click the **Edit** (pencil) icon on the top right.
+5. Click directly on the **Banner** component in the preview.
+6. Change the **Title** text and press **Save**.
+
+Watch your Next.js app update instantly!`
+ }
+ }
+ ]
+ },
+ {
+ number: 3,
+ title: `You Did It! Now what's next`,
+ videoPath: `videopath`,
+ description:
+ 'Congratulations! You have successfully built a Headless Next.js app with full visual editing capabilities. You now have a workflow where developers build components in React, and editors manage content visually.Now it is time to build our custom component.'
+ }
+ ]
+ };
+};
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts
new file mode 100644
index 000000000000..387d5519daa9
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/models.ts
@@ -0,0 +1,38 @@
+interface OnboardingSubstepExplanation {
+ title: string;
+ description: string;
+}
+
+export type SupportedFrameworks = 'nextjs' | 'angular' | 'angular-ssr' | 'astro';
+
+type SubstepType = 'file' | 'terminal' | 'config';
+
+interface OnboardingStep {
+ number: number;
+ title: string;
+ description: string;
+ videoPath?: string;
+ substeps?: OnboardingSubstep[];
+}
+
+export interface OnboardingSubstep {
+ code: string;
+ language: string;
+ explanation: OnboardingSubstepExplanation;
+ type: SubstepType;
+ filePath?: string;
+}
+
+export interface OnboardingContent {
+ title: string;
+ description: string;
+ steps: OnboardingStep[];
+}
+
+export interface OnboardingFramework {
+ id: string;
+ label: string;
+ logo: string;
+ disabled?: boolean;
+ githubUrl?: string;
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html
new file mode 100644
index 000000000000..63bce6be7693
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.html
@@ -0,0 +1,197 @@
+
+
+
+ Not a developer?
+
+
+
+ Start with dotCMS headless development in minutes
+
+
+
+ @for (framework of frameworks; track framework.id) {
+
+
+
+
+
+ {{ framework.label }}
+
+
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready to build with {{ selectedFrameworkInfo?.label }}?
+
+
+ The interactive guide is in development, but you can check out our fully configured
+ {{ selectedFrameworkInfo?.label }} starter kit today
+
+
+
+
+
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss
new file mode 100644
index 000000000000..5bfda73ead27
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.scss
@@ -0,0 +1,347 @@
+@use "variables" as *;
+
+:host {
+ display: flex;
+ height: 100%;
+ position: relative;
+ flex-direction: column;
+
+ &::ng-deep .p-accordion .p-accordion-content {
+ padding-block-start: 0;
+ padding-block-end: $spacing-4;
+ }
+}
+
+header {
+ max-width: 70ch;
+ margin-block-end: $spacing-4;
+
+ h1 {
+ font-weight: $font-weight-medium-bold;
+ font-size: $font-size-xl;
+ margin: 0;
+ margin-block-end: $spacing-1;
+ }
+
+ p {
+ margin: 0;
+ line-height: 1.5;
+ }
+}
+h2 {
+ margin: 0;
+ font-size: $font-size-md;
+ font-weight: $font-weight-medium-bold;
+}
+
+markdown {
+ font-size: 1em;
+ line-height: 1.5em;
+
+ &::ng-deep {
+ & > div:last-child pre,
+ & > div:last-child code {
+ margin-bottom: 0;
+ }
+
+ *:last-child {
+ margin-bottom: 0;
+ }
+
+ h1,
+ .h1 {
+ font-size: 4.21428571em;
+ line-height: 1.06779661em;
+ margin-top: 0.3559322em;
+ margin-bottom: 0.7118644em;
+ }
+ h2,
+ .h2 {
+ font-size: 2.64285714em;
+ line-height: 1.13513514em;
+ margin-top: 0.56756757em;
+ margin-bottom: 0.56756757em;
+ }
+ h3,
+ .h3 {
+ font-size: 1.64285714em;
+ line-height: 1.82608696em;
+ margin-top: 0.91304348em;
+ margin-bottom: 0em;
+ }
+ h4,
+ .h4 {
+ font-size: 1em;
+ line-height: 1.5em;
+ margin-top: 1.5em;
+ margin-bottom: 0em;
+ }
+ h5,
+ .h5 {
+ font-size: 1em;
+ line-height: 1.5em;
+ margin-top: 1.5em;
+ margin-bottom: 0em;
+ }
+ p,
+ ul,
+ ol,
+ pre,
+ table,
+ blockquote {
+ margin-top: 0em;
+ margin-bottom: 1.5em;
+
+ // markdown component render a inside the
tags
+ &::ng-deep markdown p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+ ul,
+ ol {
+ padding-inline-start: $spacing-4;
+ }
+ ul ul,
+ ol ol,
+ ul ol,
+ ol ul {
+ margin-top: 0em;
+ margin-bottom: 0em;
+ }
+
+ /* Let's make sure all's aligned */
+ hr,
+ .hr {
+ border: 1px solid;
+ margin: -1px 0;
+ }
+ a,
+ b,
+ i,
+ strong,
+ em,
+ small,
+ code {
+ line-height: 0;
+ }
+ sub,
+ sup {
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sup {
+ top: -0.5em;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ }
+}
+
+.logos {
+ display: flex;
+ gap: $spacing-2;
+ margin-block-end: $spacing-4;
+}
+
+.logo-item {
+ img {
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+ }
+
+ label {
+ border-radius: $border-radius-sm;
+ display: flex;
+ gap: $spacing-1;
+ align-items: center;
+ box-shadow: 0 0 0 1px $color-palette-gray-400;
+ padding: $spacing-2;
+ cursor: pointer;
+
+ &:hover {
+ box-shadow: 0 0 0 1px $color-palette-primary-400;
+ }
+ }
+
+ &:has(.p-radiobutton-checked) label {
+ box-shadow: 0 0 0 2px $color-palette-primary-500;
+ }
+
+ p-radioButton {
+ display: none;
+ }
+}
+
+.dot-onboarding {
+ padding-block-start: $spacing-4;
+ flex: 1;
+ overflow: auto;
+ position: relative;
+
+ &__step-completed {
+ color: $color-palette-green;
+ }
+
+ &__content,
+ &__progress-container {
+ max-width: 60rem;
+ margin: 0 auto;
+ padding: 0 $spacing-4;
+ }
+
+ &__intro {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-3;
+ }
+
+ &__progress {
+ background-color: $white;
+ border-top: 1px solid $color-palette-gray-400;
+ z-index: 5;
+ flex-shrink: 0;
+ }
+
+ &__progress-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &__progress-ring {
+ display: flex;
+ gap: $spacing-1;
+ align-items: center;
+ }
+}
+
+.dot-step {
+ counter-reset: step-counter; // Initialize counter for each .dot-step
+
+ h3 {
+ margin: 0 0 $spacing-1 0;
+ font-size: $font-size-md;
+ font-weight: $font-weight-medium-bold;
+ position: relative;
+ padding-inline-start: 2.4em; // Reserve space for the counter
+ counter-increment: step-counter;
+ padding-block-start: 3px; // Had to hardcode this to align the counter with the text
+
+ &::before {
+ content: counter(step-counter);
+ position: absolute;
+ left: 0;
+ top: 0;
+ font-size: $font-size-md;
+ font-weight: $font-weight-regular-bold;
+ color: $color-palette-primary-800;
+ width: 2em;
+ height: 2em;
+ text-align: right;
+ background: $color-palette-primary-200;
+ border-radius: 0.4em;
+ box-sizing: border-box;
+ display: inline-block;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ }
+ }
+
+ &__description::ng-deep p {
+ font-size: $font-size-lg;
+ line-height: 1.5;
+ padding: 0 $spacing-2;
+ color: $color-palette-gray-700;
+ }
+
+ hr {
+ margin: $spacing-4 0 $spacing-6;
+ border-color: $color-palette-gray-200;
+ border-width: 1px;
+ border-style: solid;
+ border-radius: 0.4em;
+ }
+
+ &::ng-deep {
+ markdown {
+ pre,
+ code {
+ white-space: pre; // prevent wrapping
+ overflow-x: auto;
+ max-height: 400px;
+ }
+
+ pre {
+ background-color: $black;
+ border: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ code {
+ padding: 0;
+ }
+ }
+ }
+
+ &__substep {
+ display: grid;
+ grid-template-columns: 3fr 4fr;
+ gap: $spacing-4;
+ margin-bottom: $spacing-6;
+
+ // Ensure code/pre inside right grid cell never overflow container
+ & > div:last-child {
+ min-width: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &__video {
+ width: 100%;
+ height: 360px;
+ background-color: $color-palette-gray-200;
+ margin-bottom: $spacing-4;
+ }
+
+ &__substeps {
+ padding: 0 $spacing-2;
+ margin-bottom: $spacing-6;
+ }
+
+ &__substep-description {
+ display: block;
+ margin-left: 2.2rem;
+ color: $color-palette-gray-700;
+ }
+
+ &__file-path {
+ font-family: monospace;
+ font-size: $font-size-sm;
+ color: $color-palette-gray-600;
+ background-color: $color-palette-gray-900;
+ padding: $spacing-1 $spacing-2;
+ border-radius: 0.25rem 0.25rem 0 0;
+ }
+
+ &__next-button {
+ margin-inline-start: $spacing-2;
+ }
+}
+
+.button-link {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ cursor: pointer;
+ color: #515667;
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts
new file mode 100644
index 000000000000..13cb02ffc177
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { OnboardingDevComponent } from './onboarding-dev.component';
+
+describe('OnboardingDevComponent', () => {
+ let component: OnboardingDevComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [OnboardingDevComponent]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(OnboardingDevComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts
new file mode 100644
index 000000000000..e311f9c4fad4
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/onboarding-dev.component.ts
@@ -0,0 +1,216 @@
+import { patchState } from '@ngrx/signals';
+import { MarkdownModule } from 'ngx-markdown';
+
+import { isPlatformBrowser, CommonModule } from '@angular/common';
+import {
+ Component,
+ ElementRef,
+ OnInit,
+ PLATFORM_ID,
+ ViewChild,
+ inject,
+ Output,
+ EventEmitter
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { AccordionModule } from 'primeng/accordion';
+import { AvatarModule } from 'primeng/avatar';
+import { ButtonModule } from 'primeng/button';
+import { KnobModule } from 'primeng/knob';
+import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel';
+import { ProgressBarModule } from 'primeng/progressbar';
+import { RadioButtonModule } from 'primeng/radiobutton';
+import { TabViewModule } from 'primeng/tabview';
+import { TagModule } from 'primeng/tag';
+import { TooltipModule } from 'primeng/tooltip';
+
+import { ButtonCopyComponent } from '@dotcms/ui';
+
+import { getOnboardingContent, STORAGE_KEY } from './content';
+import { OnboardingFramework, SupportedFrameworks } from './models';
+import { state } from './store';
+
+@Component({
+ selector: 'dot-onboarding-dev',
+ templateUrl: './onboarding-dev.component.html',
+ styleUrls: ['./onboarding-dev.component.scss'],
+ imports: [
+ AccordionModule,
+ ButtonCopyComponent,
+ ButtonModule,
+ CommonModule,
+ FormsModule,
+ KnobModule,
+ MarkdownModule,
+ OverlayPanelModule,
+ ProgressBarModule,
+ RadioButtonModule,
+ RouterModule,
+ TagModule,
+ TooltipModule,
+ TabViewModule,
+ AvatarModule
+ ]
+})
+export class DotOnboardingDevComponent implements OnInit {
+ readonly state = state;
+
+ @Output() eventEmitter = new EventEmitter<'reset-user-profile'>();
+
+ selectedFramework: SupportedFrameworks = 'nextjs';
+ content = getOnboardingContent(this.selectedFramework);
+ cliCommand = `npx @dotcms/create-app my-dotcms-app --directory=. --framework=${this.selectedFramework} --url=${window.location.origin} `;
+
+ frameworks: OnboardingFramework[] = [
+ {
+ id: 'nextjs',
+ label: 'Next.js',
+ logo: '/dotAdmin/assets/logos/nextjs.svg'
+ },
+ {
+ id: 'angular',
+ label: 'Angular',
+ logo: '/dotAdmin/assets/logos/angular.png',
+ disabled: false,
+ githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/angular'
+ },
+ {
+ id: 'angular-ssr',
+ label: 'Angular SSR',
+ logo: '/dotAdmin/assets/logos/angular.png',
+ disabled: false,
+ githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/angular-ssr'
+ },
+ {
+ id: 'astro',
+ label: 'Astro',
+ logo: '/dotAdmin/assets/logos/astro.svg',
+ disabled: false,
+ githubUrl: 'https://github.com/dotCMS/core/tree/main/examples/astro'
+ },
+ {
+ id: '.net',
+ label: '.Net',
+ logo: '/dotAdmin/assets/logos/dot-net.png',
+ disabled: true,
+ githubUrl: 'https://github.com/dotCMS/dotnet-starter-example'
+ },
+ {
+ id: 'php',
+ label: 'PHP',
+ logo: '/dotAdmin/assets/logos/php.png',
+ disabled: true,
+ githubUrl: 'https://github.com/dotCMS/dotnet-starter-example'
+ }
+ ];
+
+ @ViewChild('onboardingContainer', { read: ElementRef })
+ onboardingContainer?: ElementRef;
+ @ViewChild('frameworkInfoOverlay') frameworkInfoOverlay?: OverlayPanel;
+ selectedFrameworkInfo?: OnboardingFramework;
+ private readonly platformId = inject(PLATFORM_ID);
+
+ ngOnInit(): void {
+ this.loadProgress();
+ }
+
+ formatSubstep(): string {
+ const command = `\`\`\`
+${this.cliCommand}
+`;
+ return command;
+ }
+
+ activeIndexChange(newIndex: number) {
+ const totalSteps = this.content.steps.length;
+
+ // newIndex is 0-based, so we need to subtract 1 to get the correct progress
+ const progress = Math.round((newIndex / (totalSteps - 1)) * 100);
+
+ let title = 'All steps complete';
+
+ if (!totalSteps) {
+ title = 'No steps available';
+ }
+
+ if (this.content.steps[newIndex]) {
+ title = this.content.steps[newIndex].title;
+ }
+
+ patchState(state, (state) => ({
+ ...state,
+ activeAccordionIndex: newIndex,
+ progress,
+ currentStateLabel: title
+ }));
+
+ this.persistProgress();
+
+ setTimeout(() => {
+ this.scrollToActiveTab();
+ }, 500);
+ }
+
+ showFrameworkInfo(event: Event, framework: OnboardingFramework): void {
+ this.selectedFrameworkInfo = framework;
+ this.frameworkInfoOverlay?.toggle(event);
+ }
+
+ isStepCompleted(index: number): boolean {
+ return index <= state.activeAccordionIndex();
+ }
+
+ private loadProgress(): void {
+ if (!isPlatformBrowser(this.platformId)) {
+ return;
+ }
+
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+
+ if (!saved) {
+ this.activeIndexChange(0);
+ return;
+ }
+
+ this.activeIndexChange(parseInt(saved));
+ } catch {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+
+ private persistProgress(): void {
+ if (!isPlatformBrowser(this.platformId)) {
+ return;
+ }
+
+ localStorage.setItem(STORAGE_KEY, state.activeAccordionIndex().toString());
+ }
+
+ private scrollToActiveTab(): void {
+ if (!isPlatformBrowser(this.platformId) || !this.onboardingContainer?.nativeElement) {
+ return;
+ }
+ const container = this.onboardingContainer.nativeElement;
+ const activeTab = container.querySelector(
+ '.p-accordion-tab-active .p-accordion-header'
+ ) as HTMLElement;
+ activeTab.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start'
+ });
+ }
+
+ public resetUserProfile(): void {
+ localStorage.removeItem('user_profile');
+ this.eventEmitter.emit('reset-user-profile');
+ }
+
+ public changeSelectedFramework(index: number): void {
+ this.content = getOnboardingContent(this.frameworks[index].id as SupportedFrameworks);
+ this.selectedFramework = this.frameworks[index].id as SupportedFrameworks;
+ this.cliCommand = `npx @dotcms/create-app my-dotcms-app --directory=. --framework=${this.selectedFramework} --url=${window.location.origin}`;
+ }
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts
new file mode 100644
index 000000000000..7bf58013bfbb
--- /dev/null
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/components/onboarding-dev/store.ts
@@ -0,0 +1,15 @@
+import { signalState } from '@ngrx/signals';
+
+type OnBoardingState = {
+ progress: number;
+ activeAccordionIndex: number;
+ currentStateLabel: string;
+};
+
+const INITIAL_STATE: OnBoardingState = {
+ progress: 0,
+ activeAccordionIndex: 0,
+ currentStateLabel: ''
+};
+
+export const state = signalState(INITIAL_STATE);
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts
deleted file mode 100644
index 9022f0689425..000000000000
--- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.spec.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Observable, of } from 'rxjs';
-
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { TestBed, waitForAsync } from '@angular/core/testing';
-
-import { DotCurrentUserService } from '@dotcms/data-access';
-import { CoreWebService } from '@dotcms/dotcms-js';
-import { DotPermissionsType, PermissionsType, UserPermissions } from '@dotcms/dotcms-models';
-import { CoreWebServiceMock } from '@dotcms/utils-testing';
-
-import { DotStarterResolver } from './dot-starter-resolver.service';
-
-export const CurrentUserDataMock = {
- admin: true,
- email: 'admin@dotcms.com',
- givenName: 'TEST',
- roleId: 'e7d23sde-5127-45fc-8123-d424fd510e3',
- surname: 'User',
- userId: 'testId'
-};
-
-const permissionsData: DotPermissionsType = {
- STRUCTURES: { canRead: true, canWrite: true },
- HTMLPAGES: { canRead: true, canWrite: true },
- TEMPLATES: { canRead: true, canWrite: true },
- CONTENTLETS: { canRead: true, canWrite: true }
-};
-export class DotCurrentUserServiceMock {
- getCurrentUser() {
- return of(CurrentUserDataMock);
- }
-
- getUserPermissions(
- _userId: string,
- _permissions: UserPermissions[],
- _permissionsType: PermissionsType[]
- ): Observable {
- return of(permissionsData);
- }
-}
-
-describe('DotStarterResolver', () => {
- let dotStarterResolver: DotStarterResolver;
-
- beforeEach(waitForAsync(() => {
- const testbed = TestBed.configureTestingModule({
- imports: [HttpClientTestingModule],
- providers: [
- { provide: CoreWebService, useClass: CoreWebServiceMock },
- { provide: DotCurrentUserService, useClass: DotCurrentUserServiceMock },
- DotStarterResolver
- ]
- });
- dotStarterResolver = testbed.inject(DotStarterResolver);
- }));
-
- it('should get and return user & permissions data', () => {
- dotStarterResolver.resolve().subscribe(({ user, permissions }) => {
- expect(user).toEqual(CurrentUserDataMock);
- expect(permissions).toEqual(permissionsData);
- });
- });
-});
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts
deleted file mode 100644
index b4a492c5eb18..000000000000
--- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter-resolver.service.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Observable } from 'rxjs';
-
-import { Injectable, inject } from '@angular/core';
-import { Resolve } from '@angular/router';
-
-import { map, mergeMap } from 'rxjs/operators';
-
-import { DotCurrentUserService } from '@dotcms/data-access';
-import {
- DotCurrentUser,
- DotPermissionsType,
- PermissionsType,
- UserPermissions
-} from '@dotcms/dotcms-models';
-
-/**
- * Returns user's data and permissions
- *
- * @export
- * @class DotStarterResolver
- * @implements {Resolve>}
- */
-@Injectable()
-export class DotStarterResolver
- implements Resolve>
-{
- private dotCurrentUserService = inject(DotCurrentUserService);
-
- resolve(): Observable<{ user: DotCurrentUser; permissions: DotPermissionsType }> {
- return this.dotCurrentUserService.getCurrentUser().pipe(
- mergeMap((user: DotCurrentUser) => {
- return this.dotCurrentUserService
- .getUserPermissions(
- user.userId,
- [UserPermissions.WRITE],
- [
- PermissionsType.HTMLPAGES,
- PermissionsType.STRUCTURES,
- PermissionsType.TEMPLATES,
- PermissionsType.CONTENTLETS
- ]
- )
- .pipe(
- map((permissionsType: DotPermissionsType) => {
- return { user, permissions: permissionsType };
- })
- );
- })
- );
- }
-}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html
index c51657ac5ca1..5ff004383089 100644
--- a/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-starter/dot-starter.component.html
@@ -1,277 +1,49 @@
-@if (userData$ | async; as user) {
-
-