Compare commits

..

3 Commits

Author SHA1 Message Date
095cf0df34 Skip check in intervals for projects with completed auction and no winning deployers (#60)
All checks were successful
Lint / lint (20.x) (push) Successful in 1m25s
Deploy Snowball frontend / deploy (20.x) (push) Successful in 1m57s
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

- Contains formatting changes from prettier

Reviewed-on: cerc-io/snowballtools-base#60
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-07-07 08:51:54 +00:00
dd1d747b60 Implement feature to add custom network config to embedded wallet (#59)
Some checks failed
Lint / lint (20.x) (push) Failing after 0s
Deploy Snowball frontend / deploy (20.x) (push) Successful in 1m58s
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#59
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-06-24 17:06:51 +00:00
cda6ebec30 Update data structure in WALLET_ACCOUNTS_DATA iframe message (#57)
All checks were successful
Lint / lint (20.x) (push) Successful in 1m30s
Deploy Snowball frontend / deploy (20.x) (push) Successful in 2m0s
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/snowballtools-base#57
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-03-25 09:59:37 +00:00
581 changed files with 22340 additions and 30086 deletions

View File

@ -47,11 +47,11 @@ jobs:
cat > packages/deployer/config.yml <<EOF
services:
registry:
rpcEndpoint: https://laconicd-sapo.laconic.com
gqlEndpoint: https://laconicd-sapo.laconic.com/api
rpcEndpoint: https://laconicd-mainnet-1.laconic.com
gqlEndpoint: https://laconicd-mainnet-1.laconic.com/api
userKey: $REGISTRY_USER_KEY
bondId: $REGISTRY_BOND_ID
chainId: laconic-testnet-2
chainId: laconic-mainnet
gasPrice: 0.001alnt
EOF

View File

@ -1,40 +0,0 @@
name: Generate and Deploy Docs
on:
workflow_dispatch: # Allow manual triggering
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for proper branch detection
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Update TypeDoc config for current branch
run: |
CURRENT_BRANCH=${GITHUB_REF#refs/heads/}
echo "Current branch: $CURRENT_BRANCH"
# Update gitRevision in typedoc.json to use the current branch
sed -i "s|\"gitRevision\": \"qrigin/[^\"]*\"|\"gitRevision\": \"qrigin/$CURRENT_BRANCH\"|" typedoc.mjs
- name: Generate documentation
run: yarn typedoc
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: docs
clean: true
token: ${{ secrets.ACTIONS_ONLY }} # Use the ACTION_ONLY token

25
.github/workflows/lint.yaml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Lint
on:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: yarn
- name: Build libs
run: yarn workspace gql-client run build
- name: Linter check
run: yarn lint

View File

@ -0,0 +1,39 @@
name: Test webapp deployment
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
test_app_deployment:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: yarn
- name: Test webapp deployment
run: ./packages/deployer/test/test-webapp-deployment-undeployment.sh
- name: Notify Vulcanize Slack on CI failure
if: ${{ always() && github.ref_name == 'main' }}
uses: ravsamhq/notify-slack-action@v2
with:
status: ${{ job.status }}
notify_when: 'failure'
env:
SLACK_WEBHOOK_URL: ${{ secrets.VULCANIZE_SLACK_CI_ALERTS_WEBHOOK }}
- name: Notify DeepStack Slack on CI failure
if: ${{ always() && github.ref_name == 'main' }}
uses: ravsamhq/notify-slack-action@v2
with:
status: ${{ job.status }}
notify_when: 'failure'
env:
SLACK_WEBHOOK_URL: ${{ secrets.DEEPSTACK_SLACK_CI_ALERTS_WEBHOOK }}

6
.gitignore vendored
View File

@ -3,7 +3,6 @@ yarn-error.log
.yarnrc.yml
.yarn/
.yarnrc
.cursor
packages/backend/environments/local.toml
packages/backend/dev/
@ -11,8 +10,3 @@ packages/frontend/dist/
# ignore all .DS_Store files
**/.DS_Store
.vscode
# TypeDoc generated documentation
docs/
.cursor/

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
// IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [
"tv\\('([^)]*)\\')",
"(?:'|\"|`)([^\"'`]*)(?:'|\"|`)"
]
}

View File

@ -1,165 +0,0 @@
# Documentation Guide for Snowball Tools
This guide explains how to write and generate documentation for the Snowball Tools project.
## TSDoc and TypeDoc
We use [TSDoc](https://tsdoc.org/) for documenting our TypeScript code and [TypeDoc](https://typedoc.org/) for generating API documentation from those comments.
## Writing Documentation
### Basic Comment Structure
TSDoc comments start with `/**` and end with `*/`. Each line within the comment block typically starts with a `*`.
```typescript
/**
* This is a TSDoc comment.
*/
```
### Documenting Functions
```typescript
/**
* Calculates the sum of two numbers.
*
* @param a - The first number
* @param b - The second number
* @returns The sum of a and b
*
* @example
* ```ts
* const result = add(1, 2);
* console.log(result); // 3
* ```
*/
function add(a: number, b: number): number {
return a + b;
}
```
### Documenting Classes
```typescript
/**
* Represents a user in the system.
*
* @remarks
* This class is used throughout the application to represent user data.
*
* @example
* ```ts
* const user = new User('John', 'Doe');
* console.log(user.fullName); // "John Doe"
* ```
*/
class User {
/**
* Creates a new User instance.
*
* @param firstName - The user's first name
* @param lastName - The user's last name
*/
constructor(
/** The user's first name */
public firstName: string,
/** The user's last name */
public lastName: string
) {}
/**
* Gets the user's full name.
*/
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
```
### Documenting Interfaces
```typescript
/**
* Configuration options for the application.
*
* @public
*/
interface AppConfig {
/**
* The port number the server should listen on.
* @default 3000
*/
port: number;
/**
* The host the server should bind to.
* @default "localhost"
*/
host: string;
/**
* Whether to enable debug mode.
* @default false
*/
debug?: boolean;
}
```
## Common TSDoc Tags
| Tag | Description |
|-----|-------------|
| `@param` | Documents a function parameter |
| `@returns` | Documents the return value |
| `@throws` | Documents exceptions that might be thrown |
| `@example` | Provides an example of usage |
| `@remarks` | Adds additional information |
| `@deprecated` | Marks an item as deprecated |
| `@see` | Refers to related documentation |
| `@default` | Documents the default value |
| `@public`, `@protected`, `@private` | Visibility modifiers |
| `@internal` | Marks an item as internal (not part of the public API) |
| `@beta` | Marks an item as in beta stage |
| `@alpha` | Marks an item as in alpha stage |
| `@experimental` | Marks an item as experimental |
## Generating Documentation
To generate documentation for the project, run:
```bash
yarn docs
```
This will create a `docs` directory with the generated documentation.
To watch for changes and regenerate documentation automatically:
```bash
yarn docs:watch
```
## Best Practices
1. **Document Public APIs**: Always document public APIs thoroughly.
2. **Include Examples**: Provide examples for complex functions or classes.
3. **Be Concise**: Keep documentation clear and to the point.
4. **Use Proper Grammar**: Use proper grammar and punctuation.
5. **Update Documentation**: Keep documentation in sync with code changes.
6. **Document Parameters**: Document all parameters, including their types and purpose.
7. **Document Return Values**: Document what a function returns.
8. **Document Exceptions**: Document any exceptions that might be thrown.
## Example Files
For reference, check out these example files that demonstrate proper TSDoc usage:
- `packages/backend/src/utils/tsdoc-example.ts`
- `packages/frontend/src/utils/tsdoc-example.ts`
## Resources
- [TSDoc Official Documentation](https://tsdoc.org/)
- [TypeDoc Official Documentation](https://typedoc.org/)
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# snowballtools-base
This is a [yarn workspace](https://yarnpkg.com/features/workspaces) monorepo for the dashboard.
## Getting Started
### Install dependencies
In the root of the project, run:
```zsh
yarn
```
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables, running the development server, and deployment
Follow the instructions in the README.md files of the [backend](packages/backend/README.md) and [frontend](packages/frontend/README.md) packages.

View File

@ -15,7 +15,6 @@ VITE_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_github_clientid'
VITE_GITHUB_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo'
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo'
VITE_GITHUB_NEXT_APP_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_next_app_templaterepo'
VITE_LACONICD_CHAIN_ID = 'LACONIC_HOSTED_CONFIG_laconicd_chain_id'
VITE_WALLET_IFRAME_URL = 'LACONIC_HOSTED_CONFIG_wallet_iframe_url'
VITE_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key'
VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key'

View File

@ -1,114 +0,0 @@
# Snowball Tools API Documentation
This documentation is automatically generated using [TypeDoc](https://typedoc.org/) from TSDoc comments in the codebase.
## Packages
The monorepo contains the following packages:
- **frontend**: The frontend web application
- **backend**: The backend API server
- **gql-client**: GraphQL client library
- **deployer**: Deployment utilities
## How to Use This Documentation
The documentation is organized by packages and modules. You can navigate through the sidebar to explore the different parts of the codebase.
## Contributing to Documentation
To contribute to the documentation:
1. Add TSDoc comments to your code using the JSDoc syntax
2. Run `yarn docs` to generate the documentation
3. Preview the documentation in the `docs` directory
## TSDoc Comment Examples
### Function Documentation
```typescript
/**
* Calculates the sum of two numbers.
*
* @param a - The first number
* @param b - The second number
* @returns The sum of a and b
*
* @example
* ```ts
* const result = add(1, 2);
* console.log(result); // 3
* ```
*/
function add(a: number, b: number): number {
return a + b;
}
```
### Class Documentation
```typescript
/**
* Represents a user in the system.
*
* @remarks
* This class is used throughout the application to represent user data.
*
* @example
* ```ts
* const user = new User('John', 'Doe');
* console.log(user.fullName); // "John Doe"
* ```
*/
class User {
/**
* Creates a new User instance.
*
* @param firstName - The user's first name
* @param lastName - The user's last name
*/
constructor(
/** The user's first name */
public firstName: string,
/** The user's last name */
public lastName: string
) {}
/**
* Gets the user's full name.
*/
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
```
### Interface Documentation
```typescript
/**
* Configuration options for the application.
*
* @public
*/
interface AppConfig {
/**
* The port number the server should listen on.
* @default 3000
*/
port: number;
/**
* The host the server should bind to.
* @default "localhost"
*/
host: string;
/**
* Whether to enable debug mode.
* @default false
*/
debug?: boolean;
}
```

View File

@ -5,30 +5,15 @@
"packages/*"
],
"devDependencies": {
"@microsoft/tsdoc": "^0.15.1",
"chalk": "^4.1.2",
"concurrently": "^8.2.0",
"depcheck": "^1.4.2",
"husky": "^8.0.3",
"lerna": "^8.2.0",
"patch-package": "^8.0.0",
"rimraf": "^6.0.1",
"tsdoc": "^0.0.4",
"typedoc": "^0.27.9",
"typedoc-plugin-markdown": "^4.4.2",
"typedoc-unhoax-theme": "^0.4.6"
"lerna": "^8.0.0",
"patch-package": "^8.0.0"
},
"scripts": {
"prepare": "husky install",
"build": "lerna run build --stream",
"lint": "lerna run lint --stream",
"start": "yarn kill:ports && yarn dev",
"dev": "yarn && yarn build --ignore frontend && concurrently --names \"BACKEND,FRONTEND\" --prefix-colors \"blue.bold,green.bold\" --prefix \"[{name}]\" \"yarn start:backend\" \"yarn start:frontend\"",
"start:backend": "yarn workspace backend start",
"start:frontend": "yarn workspace frontend dev",
"kill:ports": "node scripts/kill-ports.js",
"docs": "yarn typedoc",
"docs:watch": "yarn typedoc --watch"
"lint": "lerna run lint --stream"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447"
}

View File

@ -54,7 +54,7 @@
"@types/cookie-session": "^2.0.49",
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4",
"better-sqlite3": "^9.4.1",
"better-sqlite3": "^9.2.2",
"copyfiles": "^2.4.1",
"prettier": "^3.1.1",
"workspace": "^0.0.1-preview.1"

View File

@ -5,7 +5,7 @@ import {
FindOneOptions,
FindOptionsWhere,
IsNull,
Not
Not,
} from 'typeorm';
import path from 'path';
import debug from 'debug';
@ -16,7 +16,7 @@ import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig } from './config';
import { User } from './entity/User';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
import { AuctionStatus, Project } from './entity/Project';
import { Deployment, DeploymentStatus } from './entity/Deployment';
import { ProjectMember } from './entity/ProjectMember';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
@ -42,7 +42,7 @@ export class Database {
database: dbPath,
entities: [path.join(__dirname, '/entity/*')],
synchronize: true,
logging: false
logging: false,
});
}
@ -54,21 +54,24 @@ export class Database {
// Load an organization if none exist
if (!organizations.length) {
const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
organizations = await loadAndSaveData(Organization, this.dataSource, [orgEntities[0]]);
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH),
);
organizations = await loadAndSaveData(Organization, this.dataSource, [
orgEntities[0],
]);
}
// Hotfix for updating old DB data
if (organizations[0].slug === 'snowball-tools-1') {
const [orgEntity] = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
const [orgEntity] = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH),
);
await this.updateOrganization(
organizations[0].id,
{
slug: orgEntity.slug as string,
name: orgEntity.name as string
}
)
await this.updateOrganization(organizations[0].id, {
slug: orgEntity.slug as string,
name: orgEntity.name as string,
});
}
}
@ -95,7 +98,7 @@ export class Database {
}
async getOrganizations(
options: FindManyOptions<Organization>
options: FindManyOptions<Organization>,
): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization);
const organizations = await organizationRepository.find(options);
@ -104,7 +107,7 @@ export class Database {
}
async getOrganization(
options: FindOneOptions<Organization>
options: FindOneOptions<Organization>,
): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization);
const organization = await organizationRepository.findOne(options);
@ -119,25 +122,34 @@ export class Database {
where: {
userOrganizations: {
member: {
id: userId
}
}
}
id: userId,
},
},
},
});
return userOrgs;
}
async addUserOrganization(data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);
async addUserOrganization(
data: DeepPartial<UserOrganization>,
): Promise<UserOrganization> {
const userOrganizationRepository =
this.dataSource.getRepository(UserOrganization);
const newUserOrganization = await userOrganizationRepository.save(data);
return newUserOrganization;
}
async updateOrganization(organizationId: string, data: DeepPartial<Organization>): Promise<boolean> {
async updateOrganization(
organizationId: string,
data: DeepPartial<Organization>,
): Promise<boolean> {
const organizationRepository = this.dataSource.getRepository(Organization);
const updateResult = await organizationRepository.update({ id: organizationId }, data);
const updateResult = await organizationRepository.update(
{ id: organizationId },
data,
);
assert(updateResult.affected);
return updateResult.affected > 0;
@ -158,7 +170,7 @@ export class Database {
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
'deployments.isCurrent = true AND deployments.isCanonical = true',
)
.leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.deployer', 'deployer')
@ -166,7 +178,7 @@ export class Database {
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', {
projectId
projectId,
})
.getOne();
@ -174,26 +186,28 @@ export class Database {
}
async allProjectsWithoutDeployments(): Promise<Project[]> {
// Fetch all projects with auction not completed and wihout any deployments
const allProjects = await this.getProjects({
where: {
auctionId: Not(IsNull()),
auctionStatus: Not(AuctionStatus.Completed),
},
relations: ['deployments'],
withDeleted: true,
});
const projects = allProjects.filter(project => {
const activeProjectsWithoutDeployments = allProjects.filter((project) => {
if (project.deletedAt !== null) return false;
return project.deployments.length === 0;
});
return projects;
return activeProjectsWithoutDeployments;
}
async getProjectsInOrganization(
userId: string,
organizationSlug: string
organizationSlug: string,
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
@ -202,7 +216,7 @@ export class Database {
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
'deployments.isCurrent = true AND deployments.isCanonical = true',
)
.leftJoin('project.projectMembers', 'projectMembers')
.leftJoin('project.organization', 'organization')
@ -210,8 +224,8 @@ export class Database {
'(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug',
{
userId,
organizationSlug
}
organizationSlug,
},
)
.getMany();
@ -222,7 +236,7 @@ export class Database {
* Get deployments with specified filter
*/
async getDeployments(
options: FindManyOptions<Deployment>
options: FindManyOptions<Deployment>,
): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployments = await deploymentRepository.find(options);
@ -239,16 +253,18 @@ export class Database {
},
where: {
project: {
id: projectId
}
id: projectId,
},
},
order: {
createdAt: 'DESC'
}
createdAt: 'DESC',
},
});
}
async getNonCanonicalDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
async getNonCanonicalDeploymentsByProjectId(
projectId: string,
): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
@ -257,18 +273,18 @@ export class Database {
},
where: {
project: {
id: projectId
id: projectId,
},
isCanonical: false
isCanonical: false,
},
order: {
createdAt: 'DESC'
}
createdAt: 'DESC',
},
});
}
async getDeployment(
options: FindOneOptions<Deployment>
options: FindOneOptions<Deployment>,
): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOne(options);
@ -290,7 +306,7 @@ export class Database {
const updatedData = {
...data,
id
id,
};
const deployment = await deploymentRepository.save(updatedData);
@ -298,7 +314,7 @@ export class Database {
}
async getProjectMembersByProjectId(
projectId: string
projectId: string,
): Promise<ProjectMember[]> {
const projectMemberRepository =
this.dataSource.getRepository(ProjectMember);
@ -306,13 +322,13 @@ export class Database {
const projectMembers = await projectMemberRepository.find({
relations: {
project: true,
member: true
member: true,
},
where: {
project: {
id: projectId
}
}
id: projectId,
},
},
});
return projectMembers;
@ -320,7 +336,7 @@ export class Database {
async getEnvironmentVariablesByProjectId(
projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable>
filter?: FindOptionsWhere<EnvironmentVariable>,
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable);
@ -328,10 +344,10 @@ export class Database {
const environmentVariables = await environmentVariableRepository.find({
where: {
project: {
id: projectId
id: projectId,
},
...filter
}
...filter,
},
});
return environmentVariables;
@ -342,7 +358,7 @@ export class Database {
this.dataSource.getRepository(ProjectMember);
const deleteResult = await projectMemberRepository.delete({
id: projectMemberId
id: projectMemberId,
});
if (deleteResult.affected) {
@ -354,20 +370,20 @@ export class Database {
async updateProjectMemberById(
projectMemberId: string,
data: DeepPartial<ProjectMember>
data: DeepPartial<ProjectMember>,
): Promise<boolean> {
const projectMemberRepository =
this.dataSource.getRepository(ProjectMember);
const updateResult = await projectMemberRepository.update(
{ id: projectMemberId },
data
data,
);
return Boolean(updateResult.affected);
}
async addProjectMember(
data: DeepPartial<ProjectMember>
data: DeepPartial<ProjectMember>,
): Promise<ProjectMember> {
const projectMemberRepository =
this.dataSource.getRepository(ProjectMember);
@ -377,7 +393,7 @@ export class Database {
}
async addEnvironmentVariables(
data: DeepPartial<EnvironmentVariable>[]
data: DeepPartial<EnvironmentVariable>[],
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable);
@ -389,25 +405,25 @@ export class Database {
async updateEnvironmentVariable(
environmentVariableId: string,
data: DeepPartial<EnvironmentVariable>
data: DeepPartial<EnvironmentVariable>,
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable);
const updateResult = await environmentVariableRepository.update(
{ id: environmentVariableId },
data
data,
);
return Boolean(updateResult.affected);
}
async deleteEnvironmentVariable(
environmentVariableId: string
environmentVariableId: string,
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable);
const deleteResult = await environmentVariableRepository.delete({
id: environmentVariableId
id: environmentVariableId,
});
if (deleteResult.affected) {
@ -424,13 +440,13 @@ export class Database {
const projectMemberWithProject = await projectMemberRepository.find({
relations: {
project: {
owner: true
owner: true,
},
member: true
member: true,
},
where: {
id: projectMemberId
}
id: projectMemberId,
},
});
if (projectMemberWithProject.length === 0) {
@ -442,7 +458,7 @@ export class Database {
async getProjectsBySearchText(
userId: string,
searchText: string
searchText: string,
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
@ -454,8 +470,8 @@ export class Database {
'(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText',
{
userId,
searchText: `%${searchText}%`
}
searchText: `%${searchText}%`,
},
)
.getMany();
@ -464,14 +480,14 @@ export class Database {
async updateDeploymentById(
deploymentId: string,
data: DeepPartial<Deployment>
data: DeepPartial<Deployment>,
): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data);
}
async updateDeployment(
criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment>
data: DeepPartial<Deployment>,
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const updateResult = await deploymentRepository.update(criteria, data);
@ -481,7 +497,7 @@ export class Database {
async updateDeploymentsByProjectIds(
projectIds: string[],
data: DeepPartial<Deployment>
data: DeepPartial<Deployment>,
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -499,8 +515,8 @@ export class Database {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOneOrFail({
where: {
id: deploymentId
}
id: deploymentId,
},
});
const deleteResult = await deploymentRepository.softRemove(deployment);
@ -508,7 +524,11 @@ export class Database {
return Boolean(deleteResult);
}
async addProject(user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
async addProject(
user: User,
organizationId: string,
data: DeepPartial<Project>,
): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project);
// TODO: Check if organization exists
@ -521,7 +541,7 @@ export class Database {
newProject.owner = user;
newProject.organization = Object.assign(new Organization(), {
id: organizationId
id: organizationId,
});
return projectRepository.save(newProject);
@ -535,12 +555,12 @@ export class Database {
async updateProjectById(
projectId: string,
data: DeepPartial<Project>
data: DeepPartial<Project>,
): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project);
const updateResult = await projectRepository.update(
{ id: projectId },
data
data,
);
return Boolean(updateResult.affected);
@ -550,11 +570,11 @@ export class Database {
const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository.findOneOrFail({
where: {
id: projectId
id: projectId,
},
relations: {
projectMembers: true
}
projectMembers: true,
},
});
const deleteResult = await projectRepository.softRemove(project);
@ -590,7 +610,7 @@ export class Database {
async updateDomainById(
domainId: string,
data: DeepPartial<Domain>
data: DeepPartial<Domain>,
): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain);
const updateResult = await domainRepository.update({ id: domainId }, data);
@ -600,39 +620,37 @@ export class Database {
async getDomainsByProjectId(
projectId: string,
filter?: FindOptionsWhere<Domain>
filter?: FindOptionsWhere<Domain>,
): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find({
relations: {
redirectTo: true
redirectTo: true,
},
where: {
project: {
id: projectId
id: projectId,
},
...filter
}
...filter,
},
});
return domains;
}
async getOldestDomainByProjectId(
projectId: string,
): Promise<Domain | null> {
async getOldestDomainByProjectId(projectId: string): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain);
const domain = await domainRepository.findOne({
where: {
project: {
id: projectId
id: projectId,
},
},
order: {
createdAt: 'ASC'
}
createdAt: 'ASC',
},
});
return domain;
@ -651,8 +669,8 @@ export class Database {
status: DeploymentStatus.Ready,
},
order: {
createdAt: 'DESC'
}
createdAt: 'DESC',
},
});
if (deployment === null) {
@ -677,7 +695,9 @@ export class Database {
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const deployer = await deployerRepository.findOne({ where: { deployerLrn } });
const deployer = await deployerRepository.findOne({
where: { deployerLrn },
});
return deployer;
}

View File

@ -9,7 +9,7 @@ import {
OneToMany,
DeleteDateColumn,
JoinTable,
ManyToMany
ManyToMany,
} from 'typeorm';
import { User } from './User';
@ -18,6 +18,12 @@ import { ProjectMember } from './ProjectMember';
import { Deployment } from './Deployment';
import { Deployer } from './Deployer';
export enum AuctionStatus {
Commit = 'commit',
Reveal = 'reveal',
Completed = 'completed',
}
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
@ -49,16 +55,23 @@ export class Project {
@Column('text', { default: '' })
description!: string;
@Column('varchar', { nullable: true })
auctionId!: string | null;
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null;
@ManyToMany(() => Deployer, (deployer) => (deployer.projects))
@ManyToMany(() => Deployer, (deployer) => deployer.projects)
@JoinTable()
deployers!: Deployer[]
deployers!: Deployer[];
@Column('varchar', { nullable: true })
auctionId!: string | null;
@Column({
enum: AuctionStatus,
// TODO: Remove later after all projects auction status have been set
default: AuctionStatus.Completed,
})
auctionStatus!: AuctionStatus;
@Column('boolean', { default: false, nullable: true })
fundsReleased!: boolean;

View File

@ -6,7 +6,13 @@ import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm';
import * as openpgp from 'openpgp';
import { Account, DEFAULT_GAS_ESTIMATION_MULTIPLIER, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import {
Account,
DEFAULT_GAS_ESTIMATION_MULTIPLIER,
Registry as LaconicRegistry,
getGasPrice,
parseGasAndFees,
} from '@cerc-io/registry-sdk';
import { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate';
import { RegistryConfig } from './config';
@ -14,17 +20,30 @@ import {
ApplicationRecord,
Deployment,
ApplicationDeploymentRequest,
ApplicationDeploymentRemovalRequest
ApplicationDeploymentRemovalRequest,
} from './entity/Deployment';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams, DeployerRecord, RegistryRecord } from './types';
import { getConfig, getRepoDetails, registryTransactionWithRetry, sleep } from './utils';
import {
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
DeployerRecord,
RegistryRecord,
} from './types';
import {
getConfig,
getRepoDetails,
registryTransactionWithRetry,
sleep,
} from './utils';
import { MsgCreateAuctionResponse } from '@cerc-io/registry-sdk/dist/proto/cerc/auction/v1/tx';
const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE =
'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord';
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer';
@ -43,7 +62,7 @@ export class Registry {
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId, gasPrice }
{ chainId: registryConfig.chainId, gasPrice },
);
}
@ -53,7 +72,7 @@ export class Registry {
commitHash,
appType,
}: {
octokit: Octokit
octokit: Octokit;
repository: string;
commitHash: string;
appType: string;
@ -61,29 +80,33 @@ export class Registry {
applicationRecordId: string;
applicationRecordData: ApplicationRecord;
}> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(octokit, repository, commitHash)
const { repo, repoUrl, packageJSON } = await getRepoDetails(
octokit,
repository,
commitHash,
);
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
const records = await this.registry.queryRecords(
{
type: APP_RECORD_TYPE,
name: packageJSON.name
name: packageJSON.name,
},
true
true,
);
// Get next version of record
const bondRecords = records.filter(
(record: any) => record.bondId === this.registryConfig.bondId
(record: any) => record.bondId === this.registryConfig.bondId,
);
const [latestBondRecord] = bondRecords.sort(
(a: any, b: any) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
new Date(b.createTime).getTime() - new Date(a.createTime).getTime(),
);
const nextVersion = semverInc(
latestBondRecord?.attributes.version ?? '0.0.0',
'patch'
'patch',
);
assert(nextVersion, 'Application record version not valid');
@ -103,9 +126,9 @@ export class Registry {
author:
typeof packageJSON.author === 'object'
? JSON.stringify(packageJSON.author)
: packageJSON.author
: packageJSON.author,
}),
...(packageJSON.version && { app_version: packageJSON.version })
...(packageJSON.version && { app_version: packageJSON.version }),
};
const result = await this.publishRecord(applicationRecord);
@ -117,18 +140,9 @@ export class Registry {
const lrn = this.getLrn(repo);
log(`Setting name: ${lrn} for record ID: ${result.id}`);
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
await sleep(SLEEP_DURATION);
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
)
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees,
);
await sleep(SLEEP_DURATION);
@ -136,11 +150,11 @@ export class Registry {
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
lrn,
},
this.registryConfig.privateKey,
fee
)
fee,
),
);
await sleep(SLEEP_DURATION);
@ -148,16 +162,28 @@ export class Registry {
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`
lrn: `${lrn}@${applicationRecord.app_version}`,
},
this.registryConfig.privateKey,
fee
)
fee,
),
);
await sleep(SLEEP_DURATION);
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`,
},
this.registryConfig.privateKey,
fee,
),
);
return {
applicationRecordId: result.id,
applicationRecordData: applicationRecord
applicationRecordData: applicationRecord,
};
}
@ -167,7 +193,7 @@ export class Registry {
auctionParams: AuctionParams,
data: DeepPartial<Deployment>,
): Promise<{
applicationDeploymentAuctionId: string;
applicationDeploymentAuction: MsgCreateAuctionResponse['auction'];
}> {
assert(data.project?.repository, 'Project repository not found');
@ -182,8 +208,11 @@ export class Registry {
const config = await getConfig();
const auctionConfig = config.auction;
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auctionResult = await registryTransactionWithRetry(() =>
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees,
);
const auctionResult = (await registryTransactionWithRetry(() =>
this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
@ -195,9 +224,9 @@ export class Registry {
numProviders: auctionParams.numProviders,
},
this.registryConfig.privateKey,
fee
)
);
fee,
),
)) as MsgCreateAuctionResponse;
if (!auctionResult.auction) {
throw new Error('Error creating auction');
@ -217,22 +246,22 @@ export class Registry {
log('Application deployment auction data:', applicationDeploymentAuction);
return {
applicationDeploymentAuctionId: auctionResult.auction.id,
applicationDeploymentAuction: auctionResult.auction!,
};
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment,
appName: string,
repository: string,
auctionId?: string | null,
lrn: string,
apiUrl: string,
environmentVariables: { [key: string]: string },
dns: string,
requesterAddress: string,
publicKey: string,
payment?: string | null
deployment: Deployment;
appName: string;
repository: string;
auctionId?: string | null;
lrn: string;
apiUrl: string;
environmentVariables: { [key: string]: string };
dns: string;
requesterAddress: string;
publicKey: string;
payment?: string | null;
}): Promise<{
applicationDeploymentRequestId: string;
applicationDeploymentRequestData: ApplicationDeploymentRequest;
@ -267,10 +296,10 @@ export class Registry {
config: JSON.stringify(hash ? { ref: hash } : {}),
meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat(
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
"EEE LLL dd HH:mm:ss 'UTC' yyyy",
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
repository_ref: data.deployment.commitHash,
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
@ -286,12 +315,12 @@ export class Registry {
return {
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData: applicationDeploymentRequest
applicationDeploymentRequestData: applicationDeploymentRequest,
};
}
async getAuctionWinningDeployerRecords(
auctionId: string
auctionId: string,
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId]);
const auctionResult = records[0];
@ -304,7 +333,7 @@ export class Registry {
paymentAddress: auctionWinner,
});
const newRecords = records.filter(record => {
const newRecords = records.filter((record) => {
return record.names !== null && record.names.length > 0;
});
@ -319,18 +348,19 @@ export class Registry {
return deployerRecords;
}
async releaseDeployerFunds(
auctionId: string
): Promise<any> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
async releaseDeployerFunds(auctionId: string): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees,
);
const auction = await registryTransactionWithRetry(() =>
this.registry.releaseFunds(
{
auctionId
auctionId,
},
this.registryConfig.privateKey,
fee
)
fee,
),
);
return auction;
@ -340,49 +370,54 @@ export class Registry {
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords(
deployments: Deployment[]
deployments: Deployment[],
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
// TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE
type: APP_DEPLOYMENT_RECORD_TYPE,
},
true
true,
);
// Filter records with ApplicationDeploymentRequestId ID
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRequestId === record.attributes.request
)
deployment.applicationDeploymentRequestId ===
record.attributes.request,
),
);
}
/**
* Fetch WebappDeployer Records by filter
*/
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<DeployerRecord[]> {
async getDeployerRecordsByFilter(filter: {
[key: string]: any;
}): Promise<DeployerRecord[]> {
return this.registry.queryRecords(
{
type: WEBAPP_DEPLOYER_RECORD_TYPE,
...filter
...filter,
},
true
true,
);
}
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
async getDeploymentRecordsByFilter(filter: {
[key: string]: any;
}): Promise<AppDeploymentRecord[]> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
...filter
...filter,
},
true
true,
);
}
@ -390,29 +425,31 @@ export class Registry {
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords(
deployments: Deployment[]
deployments: Deployment[],
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE,
},
true
true,
);
// Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs
return records.filter((record: AppDeploymentRemovalRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRemovalRequestId === record.attributes.request &&
deployment.applicationDeploymentRecordId === record.attributes.deployment
)
deployment.applicationDeploymentRemovalRequestId ===
record.attributes.request &&
deployment.applicationDeploymentRecordId ===
record.attributes.deployment,
),
);
}
/**
* Fetch record by Id
*/
* Fetch record by Id
*/
async getRecordById(id: string): Promise<RegistryRecord | null> {
const [record] = await this.registry.getRecordsByIds([id]);
return record ?? null;
@ -440,12 +477,18 @@ export class Registry {
applicationDeploymentRemovalRequest,
);
log(`Application deployment removal request record published: ${result.id}`);
log('Application deployment removal request data:', applicationDeploymentRemovalRequest);
log(
`Application deployment removal request record published: ${result.id}`,
);
log(
'Application deployment removal request data:',
applicationDeploymentRemovalRequest,
);
return {
applicationDeploymentRemovalRequestId: result.id,
applicationDeploymentRemovalRequestData: applicationDeploymentRemovalRequest
applicationDeploymentRemovalRequestData:
applicationDeploymentRemovalRequest,
};
}
@ -457,8 +500,11 @@ export class Registry {
const auctions = await this.registry.getAuctionsByIds(auctionIds);
const completedAuctions = auctions
.filter((auction: { id: string, status: string }) => auction.status === 'completed')
.map((auction: { id: string, status: string }) => auction.id);
.filter(
(auction: { id: string; status: string }) =>
auction.status === 'completed',
)
.map((auction: { id: string; status: string }) => auction.id);
return completedAuctions;
}
@ -492,27 +538,38 @@ export class Registry {
return this.registry.getAuctionsByIds([auctionId]);
}
async sendTokensToAccount(receiverAddress: string, amount: string): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
async sendTokensToAccount(
receiverAddress: string,
amount: string,
): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees,
);
const account = await this.getAccount();
const laconicClient = await this.registry.getLaconicClient(account);
const txResponse: DeliverTxResponse =
await registryTransactionWithRetry(() =>
laconicClient.sendTokens(account.address, receiverAddress,
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
() =>
laconicClient.sendTokens(
account.address,
receiverAddress,
[
{
denom: 'alnt',
amount
}
amount,
},
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER)
);
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER,
),
);
return txResponse;
}
async getAccount(): Promise<Account> {
const account = new Account(Buffer.from(this.registryConfig.privateKey, 'hex'));
const account = new Account(
Buffer.from(this.registryConfig.privateKey, 'hex'),
);
await account.init();
return account;

View File

@ -1,22 +1,22 @@
import debug from 'debug';
import express from 'express';
import cors from 'cors';
import { ApolloServer } from 'apollo-server-express';
import { createServer } from 'http';
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError,
} from 'apollo-server-core';
import { ApolloServer } from 'apollo-server-express';
import cors from 'cors';
import debug from 'debug';
import express from 'express';
import session from 'express-session';
import { createServer } from 'http';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { TypeSource } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { ServerConfig } from './config';
import { DEFAULT_GQL_PATH } from './constants';
import authRouter from './routes/auth';
import githubRouter from './routes/github';
import authRouter from './routes/auth';
import stagingRouter from './routes/staging';
import { Service } from './service';
@ -101,7 +101,7 @@ export const createAndStartServer = async (
}
app.use(
session(sessionOptions) as unknown as express.RequestHandler
session(sessionOptions)
);
server.applyMiddleware({
@ -116,9 +116,9 @@ export const createAndStartServer = async (
app.use(express.json());
app.set('service', service);
app.use('/auth', authRouter as express.RequestHandler);
app.use('/api/github', githubRouter as express.RequestHandler);
app.use('/staging', stagingRouter as express.RequestHandler);
app.use('/auth', authRouter);
app.use('/api/github', githubRouter);
app.use('/staging', stagingRouter);
app.use((err: any, req: any, res: any, next: any) => {
console.error(err);

View File

@ -7,11 +7,16 @@ import { DateTime } from 'luxon';
import { OAuthApp } from '@octokit/oauth-app';
import { Database } from './database';
import { ApplicationRecord, Deployment, DeploymentStatus, Environment } from './entity/Deployment';
import {
ApplicationRecord,
Deployment,
DeploymentStatus,
Environment,
} from './entity/Deployment';
import { Domain } from './entity/Domain';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
import { AuctionStatus, Project } from './entity/Project';
import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User';
import { Registry } from './registry';
@ -119,7 +124,8 @@ export class Service {
}
// Fetch ApplicationDeploymentRecord for deployments
const records = await this.laconicRegistry.getDeploymentRecords(deployments);
const records =
await this.laconicRegistry.getDeploymentRecords(deployments);
log(`Found ${records.length} ApplicationDeploymentRecords`);
// Update deployments for which ApplicationDeploymentRecords were returned
@ -204,7 +210,9 @@ export class Service {
return;
}
const registryRecord = await this.laconicRegistry.getRecordById(record.attributes.dns);
const registryRecord = await this.laconicRegistry.getRecordById(
record.attributes.dns,
);
if (!registryRecord) {
log(`DNS record not found for deployment ${deployment.id}`);
@ -219,7 +227,7 @@ export class Service {
resourceType: dnsRecord.attributes.resource_type,
value: dnsRecord.attributes.value,
version: dnsRecord.attributes.version,
}
};
deployment.applicationDeploymentRecordId = record.id;
deployment.applicationDeploymentRecordData = record.attributes;
@ -239,18 +247,21 @@ export class Service {
relations: {
project: true,
deployer: true,
}
},
});
if (previousCanonicalDeployment) {
// Send removal request for the previous canonical deployment and delete DB entry
if (previousCanonicalDeployment.url !== deployment.url) {
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: previousCanonicalDeployment.applicationDeploymentRecordId!,
deployerLrn: previousCanonicalDeployment.deployer.deployerLrn,
auctionId: previousCanonicalDeployment.project.auctionId,
payment: previousCanonicalDeployment.project.txHash
});
await this.laconicRegistry.createApplicationDeploymentRemovalRequest(
{
deploymentId:
previousCanonicalDeployment.applicationDeploymentRecordId!,
deployerLrn: previousCanonicalDeployment.deployer.deployerLrn,
auctionId: previousCanonicalDeployment.project.auctionId,
payment: previousCanonicalDeployment.project.txHash,
},
);
}
await this.db.deleteDeploymentById(previousCanonicalDeployment.id);
@ -261,7 +272,9 @@ export class Service {
// Release deployer funds on successful deployment
if (!deployment.project.fundsReleased) {
const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId);
const fundsReleased = await this.releaseDeployerFundsByProjectId(
deployment.projectId,
);
// Return remaining amount to owner
await this.returnUserFundsByProjectId(deployment.projectId, true);
@ -291,7 +304,7 @@ export class Service {
const oldDeployments = projectDeployments.filter(
(projectDeployment) =>
projectDeployment.deployer.deployerLrn ===
deployment.deployer.deployerLrn &&
deployment.deployer.deployerLrn &&
projectDeployment.id !== deployment.id &&
projectDeployment.isCanonical == deployment.isCanonical,
);
@ -356,24 +369,30 @@ export class Service {
async checkAuctionStatus(): Promise<void> {
const projects = await this.db.allProjectsWithoutDeployments();
const validAuctionIds = projects.map((project) => project.auctionId)
const validAuctionIds = projects
.map((project) => project.auctionId)
.filter((id): id is string => Boolean(id));
const completedAuctionIds = await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds);
const completedAuctionIds =
await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds);
const projectsToBedeployed = projects.filter((project) =>
completedAuctionIds.includes(project.auctionId!)
completedAuctionIds.includes(project.auctionId!),
);
for (const project of projectsToBedeployed) {
const deployerRecords = await this.laconicRegistry.getAuctionWinningDeployerRecords(project!.auctionId!);
const deployerRecords =
await this.laconicRegistry.getAuctionWinningDeployerRecords(
project!.auctionId!,
);
if (!deployerRecords) {
log(`No winning deployer for auction ${project!.auctionId}`);
// Return all funds to the owner
await this.returnUserFundsByProjectId(project.id, false)
await this.returnUserFundsByProjectId(project.id, false);
} else {
const deployers = await this.saveDeployersByDeployerRecords(deployerRecords);
const deployers =
await this.saveDeployersByDeployerRecords(deployerRecords);
for (const deployer of deployers) {
log(`Creating deployment for deployer ${deployer.deployerLrn}`);
await this.createDeploymentFromAuction(project, deployer);
@ -381,6 +400,10 @@ export class Service {
await this.updateProjectWithDeployer(project.id, deployer);
}
}
await this.updateProject(project.id, {
auctionStatus: AuctionStatus.Completed,
});
}
this.auctionStatusCheckTimeout = setTimeout(() => {
@ -485,12 +508,17 @@ export class Service {
return dbProjects;
}
async getNonCanonicalDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
const nonCanonicalDeployments = await this.db.getNonCanonicalDeploymentsByProjectId(projectId);
async getNonCanonicalDeploymentsByProjectId(
projectId: string,
): Promise<Deployment[]> {
const nonCanonicalDeployments =
await this.db.getNonCanonicalDeploymentsByProjectId(projectId);
return nonCanonicalDeployments;
}
async getLatestDNSRecordByProjectId(projectId: string): Promise<DNSRecordAttributes | null> {
async getLatestDNSRecordByProjectId(
projectId: string,
): Promise<DNSRecordAttributes | null> {
const dnsRecord = await this.db.getLatestDNSRecordByProjectId(projectId);
return dnsRecord;
}
@ -650,7 +678,7 @@ export class Service {
environment: Environment.Production,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployer: oldDeployment.deployer
deployer: oldDeployment.deployer,
});
return newDeployment;
@ -660,7 +688,7 @@ export class Service {
userId: string,
octokit: Octokit,
data: DeepPartial<Deployment>,
deployerLrn?: string
deployerLrn?: string,
): Promise<Deployment> {
assert(data.project?.repository, 'Project repository not found');
log(
@ -683,34 +711,58 @@ export class Service {
deployer = data.deployer;
}
const deployment = await this.createDeploymentFromData(userId, data, deployer!.deployerLrn!, applicationRecordId, applicationRecordData, false);
const deployment = await this.createDeploymentFromData(
userId,
data,
deployer!.deployerLrn!,
applicationRecordId,
applicationRecordData,
false,
);
const address = await this.getAddress();
const { repo, repoUrl } = await getRepoDetails(octokit, data.project.repository, data.commitHash);
const environmentVariablesObj = await this.getEnvVariables(data.project!.id!);
const { repo, repoUrl } = await getRepoDetails(
octokit,
data.project.repository,
data.commitHash,
);
const environmentVariablesObj = await this.getEnvVariables(
data.project!.id!,
);
// To set project DNS
if (data.environment === Environment.Production) {
const canonicalDeployment = await this.createDeploymentFromData(userId, data, deployer!.deployerLrn!, applicationRecordId, applicationRecordData, true);
const canonicalDeployment = await this.createDeploymentFromData(
userId,
data,
deployer!.deployerLrn!,
applicationRecordId,
applicationRecordData,
true,
);
// If a custom domain is present then use that as the DNS in the deployment request
const customDomain = await this.db.getOldestDomainByProjectId(data.project!.id!);
const customDomain = await this.db.getOldestDomainByProjectId(
data.project!.id!,
);
// On deleting deployment later, project canonical deployment is also deleted
// So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
const { applicationDeploymentRequestData, applicationDeploymentRequestId } =
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: canonicalDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: customDomain?.name ?? `${canonicalDeployment.project.name}`,
lrn: deployer!.deployerLrn!,
apiUrl: deployer!.deployerApiUrl!,
payment: data.project.txHash,
auctionId: data.project.auctionId,
requesterAddress: address,
publicKey: deployer!.publicKey!
});
const {
applicationDeploymentRequestData,
applicationDeploymentRequestId,
} = await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: canonicalDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: customDomain?.name ?? `${canonicalDeployment.project.name}`,
lrn: deployer!.deployerLrn!,
apiUrl: deployer!.deployerApiUrl!,
payment: data.project.txHash,
auctionId: data.project.auctionId,
requesterAddress: address,
publicKey: deployer!.publicKey!,
});
await this.db.updateDeploymentById(canonicalDeployment.id, {
applicationDeploymentRequestId,
@ -730,7 +782,7 @@ export class Service {
payment: data.project.txHash,
auctionId: data.project.auctionId,
requesterAddress: address,
publicKey: deployer!.publicKey!
publicKey: deployer!.publicKey!,
});
await this.db.updateDeploymentById(deployment.id, {
@ -743,7 +795,7 @@ export class Service {
async createDeploymentFromAuction(
project: DeepPartial<Project>,
deployer: Deployer
deployer: Deployer,
): Promise<Deployment> {
const octokit = await this.getOctokit(project.ownerId!);
const [owner, repo] = project.repository!.split('/');
@ -769,7 +821,7 @@ export class Service {
const applicationRecordId = record.id;
const applicationRecordData = record.attributes;
const deployerLrn = deployer!.deployerLrn
const deployerLrn = deployer!.deployerLrn;
// Create deployment with prod branch and latest commit
const deploymentData = {
@ -781,31 +833,49 @@ export class Service {
commitMessage: latestCommit.commit.message,
};
const deployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData, false);
const deployment = await this.createDeploymentFromData(
project.ownerId!,
deploymentData,
deployerLrn,
applicationRecordId,
applicationRecordData,
false,
);
const address = await this.getAddress();
const environmentVariablesObj = await this.getEnvVariables(project!.id!);
// To set project DNS
if (deploymentData.environment === Environment.Production) {
const canonicalDeployment = await this.createDeploymentFromData(project.ownerId!, deploymentData, deployerLrn, applicationRecordId, applicationRecordData, true);
const canonicalDeployment = await this.createDeploymentFromData(
project.ownerId!,
deploymentData,
deployerLrn,
applicationRecordId,
applicationRecordData,
true,
);
// If a custom domain is present then use that as the DNS in the deployment request
const customDomain = await this.db.getOldestDomainByProjectId(project!.id!);
const customDomain = await this.db.getOldestDomainByProjectId(
project!.id!,
);
// On deleting deployment later, project canonical deployment is also deleted
// So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later
const { applicationDeploymentRequestId, applicationDeploymentRequestData } =
await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: canonicalDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: customDomain?.name ?? `${canonicalDeployment.project.name}`,
auctionId: project.auctionId!,
lrn: deployerLrn,
apiUrl: deployer!.deployerApiUrl!,
requesterAddress: address,
publicKey: deployer!.publicKey!
});
const {
applicationDeploymentRequestId,
applicationDeploymentRequestData,
} = await this.laconicRegistry.createApplicationDeploymentRequest({
deployment: canonicalDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: customDomain?.name ?? `${canonicalDeployment.project.name}`,
auctionId: project.auctionId!,
lrn: deployerLrn,
apiUrl: deployer!.deployerApiUrl!,
requesterAddress: address,
publicKey: deployer!.publicKey!,
});
await this.db.updateDeploymentById(canonicalDeployment.id, {
applicationDeploymentRequestId,
@ -825,7 +895,7 @@ export class Service {
environmentVariables: environmentVariablesObj,
dns: `${deployment.project.name}-${deployment.id}`,
requesterAddress: address,
publicKey: deployer!.publicKey!
publicKey: deployer!.publicKey!,
});
await this.db.updateDeploymentById(deployment.id, {
@ -859,7 +929,7 @@ export class Service {
deployer: Object.assign(new Deployer(), {
deployerLrn,
}),
isCanonical
isCanonical,
});
log(`Created deployment ${newDeployment.id}`);
@ -869,11 +939,11 @@ export class Service {
async updateProjectWithDeployer(
projectId: string,
deployer: Deployer
deployer: Deployer,
): Promise<Deployer> {
const deploymentProject = await this.db.getProjects({
where: { id: projectId },
relations: ['deployers']
relations: ['deployers'],
});
if (!deploymentProject[0].deployers) {
@ -918,15 +988,22 @@ export class Service {
const prodBranch = createdTemplateRepo.data.default_branch ?? 'main';
const project = await this.addProject(user, organizationSlug, {
name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
prodBranch,
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
paymentAddress: data.paymentAddress,
txHash: data.txHash
}, lrn, auctionParams, environmentVariables);
const project = await this.addProject(
user,
organizationSlug,
{
name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
prodBranch,
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
paymentAddress: data.paymentAddress,
txHash: data.txHash,
},
lrn,
auctionParams,
environmentVariables,
);
if (!project || !project.id) {
throw new Error('Failed to create project from template');
@ -985,8 +1062,19 @@ export class Service {
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
};
const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData);
await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId });
const { applicationDeploymentAuction } =
await this.laconicRegistry.createApplicationDeploymentAuction(
repo,
octokit,
auctionParams!,
deploymentData,
);
await this.updateProject(project.id, {
auctionId: applicationDeploymentAuction!.id,
auctionStatus: applicationDeploymentAuction!.status as AuctionStatus,
});
} else {
const deployer = await this.db.getDeployerByLRN(lrn!);
@ -996,11 +1084,13 @@ export class Service {
}
if (deployer.minimumPayment && project.txHash) {
const amountToBePaid = deployer?.minimumPayment.replace(/\D/g, '').toString();
const amountToBePaid = deployer?.minimumPayment
.replace(/\D/g, '')
.toString();
const txResponse = await this.laconicRegistry.sendTokensToAccount(
deployer?.paymentAddress!,
amountToBePaid
amountToBePaid,
);
const txHash = txResponse.transactionHash;
@ -1018,12 +1108,19 @@ export class Service {
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
deployer
deployer,
};
const newDeployment = await this.createDeployment(user.id, octokit, deploymentData);
const newDeployment = await this.createDeployment(
user.id,
octokit,
deploymentData,
);
// Update project with deployer
await this.updateProjectWithDeployer(newDeployment.projectId, newDeployment.deployer);
await this.updateProjectWithDeployer(
newDeployment.projectId,
newDeployment.deployer,
);
}
await this.createRepoHook(octokit, project);
@ -1079,7 +1176,7 @@ export class Service {
where: { repository: repository.full_name },
relations: {
deployers: true,
}
},
});
if (!projects.length) {
@ -1095,25 +1192,23 @@ export class Service {
const deployers = project.deployers;
if (!deployers) {
log(`No deployer present for project ${project.id}`)
log(`No deployer present for project ${project.id}`);
return;
}
for (const deployer of deployers) {
// Create deployment with branch and latest commit in GitHub data
await this.createDeployment(project.ownerId, octokit,
{
project,
branch,
environment:
project.prodBranch === branch
? Environment.Production
: Environment.Preview,
commitHash: headCommit.id,
commitMessage: headCommit.message,
deployer: deployer
},
);
await this.createDeployment(project.ownerId, octokit, {
project,
branch,
environment:
project.prodBranch === branch
? Environment.Production
: Environment.Preview,
commitHash: headCommit.id,
commitMessage: headCommit.message,
deployer: deployer,
});
}
}
}
@ -1167,19 +1262,20 @@ export class Service {
let newDeployment: Deployment;
if (oldDeployment.project.auctionId) {
newDeployment = await this.createDeploymentFromAuction(oldDeployment.project, oldDeployment.deployer);
} else {
newDeployment = await this.createDeployment(user.id, octokit,
{
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployer: oldDeployment.deployer
}
newDeployment = await this.createDeploymentFromAuction(
oldDeployment.project,
oldDeployment.deployer,
);
} else {
newDeployment = await this.createDeployment(user.id, octokit, {
project: oldDeployment.project,
// TODO: Put isCurrent field in project
branch: oldDeployment.branch,
environment: Environment.Production,
commitHash: oldDeployment.commitHash,
commitMessage: oldDeployment.commitMessage,
deployer: oldDeployment.deployer,
});
}
return newDeployment;
@ -1218,22 +1314,26 @@ export class Service {
{ isCurrent: true },
);
if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate){
if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate) {
return false;
}
const newCurrentDeployment = await this.db.getDeployment({ where: { id: deploymentId }, relations: { project: true, deployer: true } });
const newCurrentDeployment = await this.db.getDeployment({
where: { id: deploymentId },
relations: { project: true, deployer: true },
});
if (!newCurrentDeployment) {
throw new Error(`Deployment with Id ${deploymentId} not found`);
}
const applicationDeploymentRequestData = newCurrentDeployment.applicationDeploymentRequestData;
const applicationDeploymentRequestData =
newCurrentDeployment.applicationDeploymentRequestData;
const customDomain = await this.db.getOldestDomainByProjectId(projectId);
if (customDomain && applicationDeploymentRequestData) {
applicationDeploymentRequestData.dns = customDomain.name
applicationDeploymentRequestData.dns = customDomain.name;
}
// Create a canonical deployment for the new current deployment
@ -1249,20 +1349,23 @@ export class Service {
applicationDeploymentRequestData!.meta = JSON.stringify({
...JSON.parse(applicationDeploymentRequestData!.meta),
note: `Updated by Snowball @ ${DateTime.utc().toFormat(
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
)}`
"EEE LLL dd HH:mm:ss 'UTC' yyyy",
)}`,
});
const result = await this.laconicRegistry.publishRecord(
applicationDeploymentRequestData,
);
log(`Application deployment request record published: ${result.id}`)
log(`Application deployment request record published: ${result.id}`);
const updateResult = await this.db.updateDeploymentById(canonicalDeployment.id, {
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData,
});
const updateResult = await this.db.updateDeploymentById(
canonicalDeployment.id,
{
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData,
},
);
return updateResult;
}
@ -1285,18 +1388,20 @@ export class Service {
where: {
projectId: deployment.project.id,
deployer: deployment.deployer,
isCanonical: true
isCanonical: true,
},
relations: {
project: true,
deployer: true,
},
})
});
// If the canonical deployment is not present then query the chain for the deployment record for backward compatibility
if (!canonicalDeployment) {
log(`Canonical deployment for deployment with id ${deployment.id} not found, querying the chain..`);
const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.deployer.baseDomain}`;
log(
`Canonical deployment for deployment with id ${deployment.id} not found, querying the chain..`,
);
const currentDeploymentURL = `https://${deployment.project.name.toLowerCase()}.${deployment.deployer.baseDomain}`;
const deploymentRecords =
await this.laconicRegistry.getDeploymentRecordsByFilter({
@ -1313,24 +1418,30 @@ export class Service {
}
// Multiple records are fetched, take the latest record
const latestRecord = deploymentRecords
.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())[0];
const latestRecord = deploymentRecords.sort(
(a, b) =>
new Date(b.createTime).getTime() -
new Date(a.createTime).getTime(),
)[0];
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: latestRecord.id,
deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
payment: deployment.project.txHash,
});
} else {
// If canonical deployment is found in the DB, then send the removal request with that deployment record Id
const result =
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: canonicalDeployment.applicationDeploymentRecordId!,
deployerLrn: canonicalDeployment.deployer.deployerLrn,
auctionId: canonicalDeployment.project.auctionId,
payment: canonicalDeployment.project.txHash
});
await this.laconicRegistry.createApplicationDeploymentRemovalRequest(
{
deploymentId:
canonicalDeployment.applicationDeploymentRecordId!,
deployerLrn: canonicalDeployment.deployer.deployerLrn,
auctionId: canonicalDeployment.project.auctionId,
payment: canonicalDeployment.project.txHash,
},
);
await this.db.updateDeploymentById(canonicalDeployment.id, {
status: DeploymentStatus.Deleting,
@ -1347,7 +1458,7 @@ export class Service {
deploymentId: deployment.applicationDeploymentRecordId,
deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
payment: deployment.project.txHash,
});
await this.db.updateDeploymentById(deployment.id, {
@ -1483,12 +1594,11 @@ export class Service {
return this.db.updateUser(user, data);
}
async getEnvVariables(
projectId: string,
): Promise<{ [key: string]: string }> {
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(projectId, {
environment: Environment.Production,
});
async getEnvVariables(projectId: string): Promise<{ [key: string]: string }> {
const environmentVariables =
await this.db.getEnvironmentVariablesByProjectId(projectId, {
environment: Environment.Production,
});
const environmentVariablesObj = environmentVariables.reduce(
(acc, env) => {
@ -1501,9 +1611,7 @@ export class Service {
return environmentVariablesObj;
}
async getAuctionData(
auctionId: string
): Promise<any> {
async getAuctionData(auctionId: string): Promise<any> {
const auctions = await this.laconicRegistry.getAuctionData(auctionId);
return auctions[0];
}
@ -1512,16 +1620,19 @@ export class Service {
const project = await this.db.getProjectById(projectId);
if (!project || !project.auctionId) {
log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`);
log(
`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`,
);
return false;
}
const auction = await this.laconicRegistry.releaseDeployerFunds(project.auctionId);
const auction = await this.laconicRegistry.releaseDeployerFunds(
project.auctionId,
);
if (auction.auction.fundsReleased) {
log(`Funds released for auction ${project.auctionId}`);
await this.db.updateProjectById(projectId, { fundsReleased: true });
return true;
}
@ -1531,21 +1642,29 @@ export class Service {
return false;
}
async returnUserFundsByProjectId(projectId: string, winningDeployersPresent: boolean) {
async returnUserFundsByProjectId(
projectId: string,
winningDeployersPresent: boolean,
) {
const project = await this.db.getProjectById(projectId);
if (!project || !project.auctionId) {
log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`);
log(
`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`,
);
return false;
}
const auction = await this.getAuctionData(project.auctionId);
const totalAuctionPrice = Number(auction.maxPrice.quantity) * auction.numProviders;
const totalAuctionPrice =
Number(auction.maxPrice.quantity) * auction.numProviders;
let amountToBeReturned;
if (winningDeployersPresent) {
amountToBeReturned = totalAuctionPrice - auction.winnerAddresses.length * Number(auction.winnerPrice.quantity);
amountToBeReturned =
totalAuctionPrice -
auction.winnerAddresses.length * Number(auction.winnerPrice.quantity);
} else {
amountToBeReturned = totalAuctionPrice;
}
@ -1553,7 +1672,7 @@ export class Service {
if (amountToBeReturned !== 0) {
await this.laconicRegistry.sendTokensToAccount(
project.paymentAddress,
amountToBeReturned.toString()
amountToBeReturned.toString(),
);
}
}
@ -1572,13 +1691,16 @@ export class Service {
}
async updateDeployersFromRegistry(): Promise<Deployer[]> {
const deployerRecords = await this.laconicRegistry.getDeployerRecordsByFilter({});
const deployerRecords =
await this.laconicRegistry.getDeployerRecordsByFilter({});
await this.saveDeployersByDeployerRecords(deployerRecords);
return await this.db.getDeployers();
}
async saveDeployersByDeployerRecords(deployerRecords: DeployerRecord[]): Promise<Deployer[]> {
async saveDeployersByDeployerRecords(
deployerRecords: DeployerRecord[],
): Promise<Deployer[]> {
const deployers: Deployer[] = [];
for (const record of deployerRecords) {
@ -1589,7 +1711,9 @@ export class Service {
const minimumPayment = record.attributes.minimumPayment;
const paymentAddress = record.attributes.paymentAddress;
const publicKey = record.attributes.publicKey;
const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1);
const baseDomain = deployerApiUrl.substring(
deployerApiUrl.indexOf('.') + 1,
);
const deployerData = {
deployerLrn,
@ -1598,7 +1722,7 @@ export class Service {
baseDomain,
minimumPayment,
paymentAddress,
publicKey
publicKey,
};
// TODO: Update deployers table in a separate job
@ -1616,25 +1740,39 @@ export class Service {
return account.address;
}
async verifyTx(txHash: string, amountSent: string, senderAddress: string): Promise<boolean> {
async verifyTx(
txHash: string,
amountSent: string,
senderAddress: string,
): Promise<boolean> {
const txResponse = await this.laconicRegistry.getTxResponse(txHash);
if (!txResponse) {
log('Transaction response not found');
return false;
}
const transfer = txResponse.events.find(e => e.type === 'transfer' && e.attributes.some(a => a.key === 'msg_index'));
const transfer = txResponse.events.find(
(e) =>
e.type === 'transfer' &&
e.attributes.some((a) => a.key === 'msg_index'),
);
if (!transfer) {
log('No transfer event found');
return false;
}
const sender = transfer.attributes.find(a => a.key === 'sender')?.value;
const recipient = transfer.attributes.find(a => a.key === 'recipient')?.value;
const amount = transfer.attributes.find(a => a.key === 'amount')?.value;
const sender = transfer.attributes.find((a) => a.key === 'sender')?.value;
const recipient = transfer.attributes.find(
(a) => a.key === 'recipient',
)?.value;
const amount = transfer.attributes.find((a) => a.key === 'amount')?.value;
const recipientAddress = await this.getAddress();
return amount === amountSent && sender === senderAddress && recipient === recipientAddress;
return (
amount === amountSent &&
sender === senderAddress &&
recipient === recipientAddress
);
}
}

View File

@ -1,237 +0,0 @@
/**
* This file demonstrates proper TSDoc usage for the Snowball Tools project.
*
* @packageDocumentation
* @module Utils
*/
/**
* Configuration options for the application.
*
* @public
*/
export interface AppConfig {
/**
* The port number the server should listen on.
* @default 3000
*/
port: number;
/**
* The host the server should bind to.
* @default "localhost"
*/
host: string;
/**
* Whether to enable debug mode.
* @default false
*/
debug?: boolean;
}
/**
* Represents a user in the system.
*
* @remarks
* This class is used throughout the application to represent user data.
*
* @example
* ```ts
* const user = new User('John', 'Doe');
* console.log(user.fullName); // "John Doe"
* ```
*/
export class User {
/**
* Creates a new User instance.
*
* @param firstName - The user's first name
* @param lastName - The user's last name
*/
constructor(
/** The user's first name */
public firstName: string,
/** The user's last name */
public lastName: string
) {}
/**
* Gets the user's full name.
*
* @returns The user's full name as a string
*/
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
/**
* Updates the user's name.
*
* @param firstName - The new first name
* @param lastName - The new last name
* @returns The updated user instance
*
* @throws {Error} If either name is empty
*/
updateName(firstName: string, lastName: string): User {
if (!firstName || !lastName) {
throw new Error('First name and last name cannot be empty');
}
this.firstName = firstName;
this.lastName = lastName;
return this;
}
}
/**
* Calculates the sum of two numbers.
*
* @param a - The first number
* @param b - The second number
* @returns The sum of a and b
*
* @example
* ```ts
* const result = add(1, 2);
* console.log(result); // 3
* ```
*/
export function add(a: number, b: number): number {
return a + b;
}
/**
* Formats a date according to the specified format.
*
* @param date - The date to format
* @param format - The format string (default: 'YYYY-MM-DD')
* @returns The formatted date string
*
* @remarks
* This function uses a simple format string where:
* - YYYY: 4-digit year
* - MM: 2-digit month
* - DD: 2-digit day
*
* @example
* ```ts
* const date = new Date('2023-01-15');
* const formatted = formatDate(date);
* console.log(formatted); // "2023-01-15"
* ```
*/
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day);
}
/**
* Type of log level for the application.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
/**
* A simple logger utility.
*
* @remarks
* This logger provides methods for different log levels and can be configured
* to filter logs based on minimum level.
*/
export class Logger {
/**
* Creates a new Logger instance.
*
* @param name - The name of the logger
* @param minLevel - The minimum log level to display (default: 'info')
*/
constructor(
private name: string,
private minLevel: LogLevel = 'info'
) {}
/**
* Logs a debug message.
*
* @param message - The message to log
* @param data - Optional data to include
*/
debug(message: string, data?: unknown): void {
this.log('debug', message, data);
}
/**
* Logs an info message.
*
* @param message - The message to log
* @param data - Optional data to include
*/
info(message: string, data?: unknown): void {
this.log('info', message, data);
}
/**
* Logs a warning message.
*
* @param message - The message to log
* @param data - Optional data to include
*/
warn(message: string, data?: unknown): void {
this.log('warn', message, data);
}
/**
* Logs an error message.
*
* @param message - The message to log
* @param error - Optional error to include
*/
error(message: string, error?: Error): void {
this.log('error', message, error);
}
/**
* Internal method to handle logging.
*
* @param level - The log level
* @param message - The message to log
* @param data - Optional data to include
*
* @internal
*/
private log(level: LogLevel, message: string, data?: unknown): void {
const levels: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
if (levels[level] >= levels[this.minLevel]) {
const timestamp = new Date().toISOString();
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] [${this.name}] ${message}`;
switch (level) {
case 'debug':
console.debug(formattedMessage, data || '');
break;
case 'info':
console.info(formattedMessage, data || '');
break;
case 'warn':
console.warn(formattedMessage, data || '');
break;
case 'error':
console.error(formattedMessage, data || '');
break;
}
}
}
}

View File

@ -1,8 +1,8 @@
services:
registry:
rpcEndpoint: https://laconicd-sapo.laconic.com
gqlEndpoint: https://laconicd-sapo.laconic.com/api
rpcEndpoint: https://laconicd-mainnet-1.laconic.com
gqlEndpoint: https://laconicd-mainnet-1.laconic.com/api
userKey:
bondId:
chainId: laconic-testnet-2
chainId: laconic-mainnet
gasPrice: 0.001alnt

View File

@ -133,7 +133,6 @@ record:
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_next_app_templaterepo: laconic-templates/starter.nextjs-react-tailwind
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
LACONIC_HOSTED_CONFIG_wallet_iframe_url: https://wallet.laconic.com
meta:
note: Added @ $CURRENT_DATE_TIME

View File

@ -12,5 +12,4 @@ VITE_BUGSNAG_API_KEY=
VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL=
VITE_LACONICD_CHAIN_ID=
VITE_WALLET_IFRAME_URL=

View File

@ -16,14 +16,10 @@
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.local.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*storybook.log
.cursor/
*storybook.log

View File

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -20,36 +20,16 @@
"@emotion/styled": "^11.13.0",
"@fontsource-variable/jetbrains-mono": "^5.0.19",
"@fontsource/inter": "^5.0.16",
"@hookform/resolvers": "^4.1.0",
"@mui/material": "^6.1.3",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@snowballtools/material-tailwind-react-fork": "^2.1.10",
"@snowballtools/smartwallet-alchemy-light": "^0.2.0",
"@snowballtools/types": "^0.2.0",
@ -64,45 +44,31 @@
"@web3modal/siwe": "4.0.5",
"assert": "^2.1.0",
"axios": "^1.6.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"downshift": "^8.3.2",
"embla-carousel-react": "^8.5.2",
"clsx": "^2.1.0",
"date-fns": "^3.3.1",
"ethers": "^5.6.2",
"downshift": "^8.3.2",
"framer-motion": "^11.0.8",
"gql-client": "^1.0.0",
"input-otp": "^1.4.2",
"lottie-react": "^2.4.0",
"lucide-react": "^0.475.0",
"luxon": "^3.4.4",
"next-themes": "^0.4.4",
"octokit": "^3.1.2",
"react": "^18.2.0",
"react-calendar": "^4.8.0",
"react-code-blocks": "^0.1.6",
"react-day-picker": "8.10.1",
"react-day-picker": "^8.9.1",
"react-dom": "^18.2.0",
"react-dropdown": "^1.11.0",
"react-hook-form": "^7.54.2",
"react-hook-form": "^7.49.0",
"react-oauth-popup": "^1.0.5",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.20.1",
"react-timer-hook": "^3.0.7",
"recharts": "^2.15.1",
"siwe": "2.1.4",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",
"tailwind-variants": "^0.2.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.15.1",
"uuid": "^9.0.1",
"vaul": "^1.1.2",
"viem": "^2.7.11",
"web-vitals": "^2.1.4",
"zod": "^3.24.2",
"zustand": "^5.0.3"
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.3.3",
@ -117,7 +83,7 @@
"@types/jest": "^27.5.2",
"@types/lodash": "^4.17.0",
"@types/luxon": "^3.3.7",
"@types/node": "^22.13.5",
"@types/node": "^16.18.68",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/uuid": "^9.0.8",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
{
"chainId": "laconic-mainnet",
"networkName": "laconicd mainnet",
"namespace": "cosmos",
"rpcUrl": "https://laconicd-mainnet-1.laconic.com",
"blockExplorerUrl": "",
"nativeDenom": "alnt",
"addressPrefix": "laconic",
"coinType": 118,
"gasPrice": 0.001
}

View File

@ -1,171 +1,85 @@
import ProjectSearchLayout from '@/layouts/ProjectSearch';
import ProjectsScreen from '@/pages/org-slug/ProjectsScreen';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { LoadingOverlay } from './components/loading/loading-overlay';
import { DashboardLayout } from './layouts/DashboardLayout';
import RootLayout from './layouts/RootLayout';
import Projects from './pages/org-slug';
import Settings from './pages/org-slug/Settings';
import {
projectsRoutesWithSearch,
projectsRoutesWithoutSearch,
} from './pages/org-slug/projects/routes';
import ProjectSearchLayout from './layouts/ProjectSearch';
import Index from './pages';
import AuthPage from './pages/AuthPage';
import { DashboardLayout } from './pages/org-slug/layout';
import { BASE_URL } from 'utils/constants';
import BuyPrepaidService from './pages/BuyPrepaidService';
import OnboardingDemoPage from './pages/OnboardingDemoPage';
import OnboardingPage from './pages/OnboardingPage';
import {
projectsRoutesWithoutSearch,
projectsRoutesWithSearch,
} from './pages/org-slug/projects/project-routes';
import Settings from './pages/org-slug/Settings';
import { BASE_URL } from './utils/constants';
/**
* IframeLoader component that ensures wallet iframe is loaded
* before rendering children components that depend on it.
*
* TEMPORARY SOLUTION: This is a quick fix for iframe loading issues.
*
* TODO: Future Refactoring Plan (Medium effort, 4-8 hours):
* - Move iframe management directly into WalletContextProvider
* - Handle multiple wallet-related iframes in a single location
*/
const IframeLoader = ({ children }: { children: React.ReactNode }) => {
const [iframeLoaded, setIframeLoaded] = useState(false);
useEffect(() => {
const createIframe = () => {
// Check if iframe already exists
let iframe = document.getElementById(
'wallet-iframe',
) as HTMLIFrameElement;
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'wallet-iframe';
iframe.style.display = 'none';
iframe.src = `${window.location.origin}/wallet-iframe.html`;
iframe.onload = () => {
setIframeLoaded(true);
};
document.body.appendChild(iframe);
} else {
// If iframe already exists, consider it loaded
setIframeLoaded(true);
}
};
createIframe();
// Cleanup function
return () => {
const iframe = document.getElementById('wallet-iframe');
if (iframe && iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
};
}, []);
if (!iframeLoaded) {
return <LoadingOverlay />;
}
return <>{children}</>;
};
// Wrap RootLayout with IframeLoader
const LoaderWrappedRootLayout = () => (
<IframeLoader>
<RootLayout />
</IframeLoader>
);
const router = createBrowserRouter([
{
element: <LoaderWrappedRootLayout />,
path: ':orgSlug',
element: <DashboardLayout />,
children: [
{
path: ':orgSlug',
element: <DashboardLayout />,
element: <ProjectSearchLayout />,
children: [
{
element: <ProjectSearchLayout />,
children: [
{
path: '',
element: <ProjectsScreen />,
},
{
path: 'projects',
children: projectsRoutesWithSearch,
},
],
},
{
path: 'settings',
element: <Settings />,
path: '',
element: <Projects />,
},
{
path: 'projects',
children: projectsRoutesWithoutSearch,
children: projectsRoutesWithSearch,
},
],
},
{
path: '/',
element: <Index />,
path: 'settings',
element: <Settings />,
},
{
path: '/login',
element: <AuthPage />,
},
{
path: '/buy-prepaid-service',
element: <BuyPrepaidService />,
errorElement: <div>Something went wrong!</div>,
},
{
path: '/onboarding',
element: <OnboardingPage />,
},
{
path: '/onboarding-demo',
element: <OnboardingDemoPage />,
path: 'projects',
children: projectsRoutesWithoutSearch,
},
],
},
{
path: '/',
element: <Index />,
},
{
path: '/login',
element: <AuthPage />,
},
{
path: '/buy-prepaid-service',
element: <BuyPrepaidService />,
},
]);
/**
* Main application component.
* Sets up routing and error handling.
* @returns {JSX.Element} The rendered application.
*/
function App() {
// Hacky way of checking session
// TODO: Handle redirect backs
useEffect(() => {
fetch(`${BASE_URL}/auth/session`, {
credentials: 'include',
}).then((res) => {
const path = window.location.pathname;
const publicPaths = ['/login', '/onboarding', '/onboarding-demo', '/'];
console.log(res);
if (res.status !== 200) {
localStorage.clear();
if (!publicPaths.includes(path)) {
if (path !== '/login') {
window.location.pathname = '/login';
}
} else {
if (path === '/login') {
window.location.pathname = '/deploy-tools';
window.location.pathname = '/';
}
}
});
}, []);
return (
<RouterProvider router={router} fallbackElement={<LoadingOverlay />} />
<RouterProvider router={router} />
);
}

View File

@ -1,8 +1,8 @@
import {
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO,
VITE_GITHUB_NEXT_APP_TEMPLATE_REPO,
VITE_GITHUB_PWA_TEMPLATE_REPO,
} from '@/utils/constants';
VITE_GITHUB_NEXT_APP_TEMPLATE_REPO,
} from 'utils/constants';
export default [
{

View File

@ -0,0 +1,219 @@
import React from 'react';
type Props = React.PropsWithChildren<{
className?: string;
snowZIndex?: number;
}>;
export const CloudyFlow = ({ className, children, snowZIndex }: Props) => {
return (
<div className={`bg-sky-100 relative ${className || ''}`}>
{children}
<div
className="absolute inset-0 overflow-hidden"
style={{ zIndex: snowZIndex || 0 }}
>
<div className="w-[3.72px] h-[3.72px] left-[587px] top-[147px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.72px] h-[4.72px] left-[742px] top-[336px] absolute bg-white rounded-full" />
<div className="w-[3.49px] h-[3.49px] left-[36px] top-[68px] absolute bg-white rounded-full" />
<div className="w-[3.25px] h-[3.25px] left-[55px] top-[114px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.60px] h-[5.60px] left-[1334px] top-[63px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.53px] h-[3.53px] left-[988px] top-[108px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.65px] h-[2.65px] left-[1380px] top-[16px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.60px] h-[3.60px] left-[1284px] top-[95px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-0.5 h-0.5 left-[1191px] top-[376px] absolute bg-white rounded-full" />
<div className="w-[2.83px] h-[2.83px] left-[1182px] top-[257px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.41px] h-[2.41px] left-[627px] top-[26px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.71px] h-[5.71px] left-[30px] top-[33px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.09px] h-[4.09px] left-[425px] top-[386px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.38px] h-[3.38px] left-[394px] top-[29px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.70px] h-[4.70px] left-[817px] top-[113px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-1.5 h-1.5 left-[1194px] top-[332px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.89px] h-[4.89px] left-[811px] top-[76px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.25px] h-[4.25px] left-[458px] top-[366px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.82px] h-[4.82px] left-[936px] top-[46px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.74px] h-[3.74px] left-[64px] top-[132px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-1 h-1 left-[763px] top-[10px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.67px] h-[3.67px] left-[861px] top-[106px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.62px] h-[3.62px] left-[710px] top-[278px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.45px] h-[3.45px] left-[1069px] top-[329px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.92px] h-[2.92px] left-[1286px] top-[299px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.84px] h-[4.84px] left-[219px] top-[269px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.39px] h-[2.39px] left-[817px] top-[121px] absolute bg-white rounded-full" />
<div className="w-[5.83px] h-[5.83px] left-[168px] top-[320px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.94px] h-[5.94px] left-[419px] top-[244px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.67px] h-[4.67px] left-[604px] top-[309px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.87px] h-[5.87px] left-[1098px] top-[379px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.85px] h-[5.85px] left-[644px] top-[352px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.19px] h-[4.19px] left-[1361px] top-[349px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.84px] h-[2.84px] left-[1299px] top-[194px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.51px] h-[4.51px] left-[468px] top-[319px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.73px] h-[2.73px] left-[1084px] top-[86px] absolute bg-white rounded-full" />
<div className="w-[3.43px] h-[3.43px] left-[1271px] top-[28px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.25px] h-[2.25px] left-[106px] top-[197px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.82px] h-[2.82px] left-[122px] top-[173px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.89px] h-[2.89px] left-[343px] top-[345px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.82px] h-[2.82px] left-[433px] top-[40px] absolute bg-white rounded-full" />
<div className="w-[4.11px] h-[4.11px] left-[904px] top-[350px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.42px] h-[4.42px] left-[1066px] top-[349px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.67px] h-[4.67px] left-[904px] top-[317px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.54px] h-[5.54px] left-[501px] top-[336px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.11px] h-[4.11px] left-[1149px] top-[206px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.55px] h-[3.55px] left-[235px] top-[362px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.60px] h-[2.60px] left-[1246px] top-[1px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.94px] h-[2.94px] left-[788px] top-[6px] absolute bg-white rounded-full" />
<div className="w-[4.19px] h-[4.19px] left-[527px] top-[365px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.13px] h-[4.13px] left-[201px] top-[53px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.94px] h-[2.94px] left-[765px] top-[13px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.11px] h-[4.11px] left-[1254px] top-[30px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.85px] h-[3.85px] left-[107px] top-[316px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.72px] h-[5.72px] left-[1305px] top-[8px] absolute bg-white rounded-full" />
<div className="w-[5.46px] h-[5.46px] left-[102px] top-[316px] absolute bg-white rounded-full" />
<div className="w-[3.77px] h-[3.77px] left-[1322px] top-[334px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.84px] h-[4.84px] left-[1370px] top-[317px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.55px] h-[5.55px] left-[945px] top-[258px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.24px] h-[2.24px] left-[266px] top-[362px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.89px] h-[2.89px] left-[987px] top-[156px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.46px] h-[3.46px] left-[10px] top-[168px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.67px] h-[5.67px] left-[441px] top-[291px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.07px] h-[4.07px] left-[962px] top-[364px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.57px] h-[5.57px] left-[599px] top-[293px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.41px] h-[4.41px] left-[358px] top-[163px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.31px] h-[2.31px] left-[670px] top-[182px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.60px] h-[2.60px] left-[621px] top-[257px] absolute bg-white rounded-full" />
<div className="w-[2.16px] h-[2.16px] left-[48px] top-[322px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.91px] h-[5.91px] left-[491px] top-[5px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.50px] h-[5.50px] left-[1139px] top-[274px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.74px] h-[3.74px] left-[24px] top-[177px] absolute bg-white rounded-full" />
<div className="w-[5.57px] h-[5.57px] left-[1166px] top-[316px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5px] h-[5px] left-[445px] top-[326px] absolute bg-white rounded-full" />
<div className="w-[3.01px] h-[3.01px] left-[438px] top-[252px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.14px] h-[4.14px] left-[554px] top-[131px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.30px] h-[5.30px] left-[1010px] top-[116px] absolute bg-white rounded-full" />
<div className="w-[5.53px] h-[5.53px] left-[437px] top-[367px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.87px] h-[5.87px] left-[948px] top-[27px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.87px] h-[2.87px] left-[826px] top-[20px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.89px] h-[3.89px] left-[1222px] top-[112px] absolute bg-white rounded-full" />
<div className="w-[3.77px] h-[3.77px] left-[796px] top-[395px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.09px] h-[2.09px] left-[272px] top-[103px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.12px] h-[4.12px] left-[76px] top-[2px] absolute bg-white rounded-full" />
<div className="w-[3.51px] h-[3.51px] left-[226px] top-[276px] absolute bg-white rounded-full" />
<div className="w-[3.03px] h-[3.03px] left-[723px] top-[197px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.14px] h-[2.14px] left-[1259px] top-[17px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.28px] h-[3.28px] left-[1244px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.45px] h-[4.45px] left-[118px] top-[128px] absolute bg-white rounded-full" />
<div className="w-[4.15px] h-[4.15px] left-[490px] top-[204px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[4.93px] h-[4.93px] left-[552px] top-[38px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.56px] h-[5.56px] left-[115px] top-[303px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.35px] h-[2.35px] left-[509px] top-[278px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.24px] h-[5.24px] left-[804px] top-[389px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.44px] h-[2.44px] left-[1013px] top-[50px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.69px] h-[3.69px] left-[1183px] top-[95px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.83px] h-[2.83px] left-[278px] top-[181px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.22px] h-[3.22px] left-[1316px] top-[282px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.55px] h-[3.55px] left-[736px] top-[119px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.29px] h-[2.29px] left-[483px] top-[319px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.14px] h-[2.14px] left-[1135px] top-[19px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.64px] h-[3.64px] left-[39px] top-[126px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.30px] h-[5.30px] left-[237px] top-[369px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.57px] h-[5.57px] left-[1156px] top-[126px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.78px] h-[2.78px] left-[1295px] top-[74px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-0.5 h-0.5 left-[76px] top-[227px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.61px] h-[3.61px] left-[108px] top-[89px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.37px] h-[5.37px] left-[191px] top-[167px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.18px] h-[4.18px] left-[164px] top-[117px] absolute bg-white rounded-full" />
<div className="w-[5.15px] h-[5.15px] left-[533px] top-[261px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-1.5 h-1.5 left-[327px] top-[157px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.74px] h-[5.74px] left-[1242px] top-[122px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.22px] h-[4.22px] left-[129px] top-[265px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.30px] h-[2.30px] left-[1305px] top-[86px] absolute bg-white rounded-full" />
<div className="w-[2.70px] h-[2.70px] left-[1235px] top-[120px] absolute bg-white rounded-full" />
<div className="w-[2.15px] h-[2.15px] left-[596px] top-[103px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.17px] h-[2.17px] left-[483px] top-[233px] absolute bg-white rounded-full" />
<div className="w-[5.09px] h-[5.09px] left-[706px] top-[188px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.15px] h-[4.15px] left-[141px] top-[2px] absolute bg-white rounded-full" />
<div className="w-[4.20px] h-[4.20px] left-[48px] top-[124px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.51px] h-[3.51px] left-[1095px] top-[201px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.21px] h-[3.21px] left-[730px] top-[185px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.61px] h-[2.61px] left-[722px] top-[319px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.28px] h-[2.28px] left-[444px] top-[26px] absolute bg-white rounded-full" />
<div className="w-[4.49px] h-[4.49px] left-[355px] top-[212px] absolute bg-white rounded-full" />
<div className="w-[3.69px] h-[3.69px] left-[1280px] top-[312px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.23px] h-[4.23px] left-[1114px] top-[113px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.48px] h-[3.48px] left-[729px] top-[117px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.11px] h-[4.11px] left-[647px] top-[276px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.16px] h-[4.16px] left-[365px] top-[116px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.35px] h-[5.35px] left-[94px] top-[194px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.84px] h-[5.84px] left-[2px] top-[84px] absolute bg-white rounded-full" />
<div className="w-[4.43px] h-[4.43px] left-[1382px] top-[23px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.38px] h-[5.38px] left-[857px] top-[284px] absolute bg-white rounded-full" />
<div className="w-[2.77px] h-[2.77px] left-[1228px] top-[385px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.65px] h-[4.65px] left-[165px] top-[184px] absolute bg-white rounded-full" />
<div className="w-[5.53px] h-[5.53px] left-[568px] top-[354px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.59px] h-[3.59px] left-[1303px] top-[371px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.84px] h-[5.84px] left-[235px] top-[188px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.84px] h-[3.84px] left-[902px] top-[211px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.45px] h-[3.45px] left-[367px] top-[161px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.08px] h-[4.08px] left-[855px] top-[394px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.25px] h-[3.25px] left-[383px] top-[47px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.39px] h-[4.39px] left-[1313px] top-[165px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.60px] h-[5.60px] left-[697px] top-[327px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.09px] h-[2.09px] left-[646px] top-[370px] absolute bg-white rounded-full" />
<div className="w-[3.13px] h-[3.13px] left-[728px] top-[122px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.53px] h-[5.53px] left-[203px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.83px] h-[5.83px] left-[424px] top-[121px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.82px] h-[4.82px] left-[1358px] top-[176px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.18px] h-[3.18px] left-[1212px] top-[24px] absolute bg-white rounded-full" />
<div className="w-[5.23px] h-[5.23px] left-[260px] top-[217px] absolute bg-white rounded-full" />
<div className="w-[5.29px] h-[5.29px] left-[1204px] top-[367px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.47px] h-[3.47px] left-[1163px] top-[159px] absolute bg-white rounded-full" />
<div className="w-[5.77px] h-[5.77px] left-[1257px] top-[115px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.31px] h-[5.31px] left-[222px] top-[356px] absolute bg-white rounded-full" />
<div className="w-[5.43px] h-[5.43px] left-[1141px] top-[349px] absolute bg-white rounded-full" />
<div className="w-[5.62px] h-[5.62px] left-[683px] top-[81px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.91px] h-[3.91px] left-[269px] top-[3px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.51px] h-[3.51px] left-[305px] top-[310px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.41px] h-[5.41px] left-[530px] top-[94px] absolute bg-white rounded-full" />
<div className="w-[4.64px] h-[4.64px] left-[730px] top-[301px] absolute bg-white rounded-full" />
<div className="w-[3.59px] h-[3.59px] left-[716px] top-[14px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.77px] h-[4.77px] left-[544px] top-[13px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.29px] h-[2.29px] left-[357px] top-[281px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.42px] h-[2.42px] left-[1346px] top-[112px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.42px] h-[3.42px] left-[671px] top-[150px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.40px] h-[4.40px] left-[1324px] top-[268px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.21px] h-[5.21px] left-[1028px] top-[376px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.27px] h-[4.27px] left-[499px] top-[50px] absolute bg-white rounded-full" />
<div className="w-[4.35px] h-[4.35px] left-[543px] top-[359px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.25px] h-[5.25px] left-[1245px] top-[296px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.52px] h-[5.52px] left-[360px] top-[98px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.46px] h-[4.46px] left-[741px] top-[358px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[3.90px] h-[3.90px] left-[1262px] top-[184px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[5.75px] h-[5.75px] left-[552px] top-[335px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[4.95px] h-[4.95px] left-[120px] top-[178px] absolute bg-white rounded-full" />
<div className="w-[3.28px] h-[3.28px] left-[1337px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.43px] h-[2.43px] left-[233px] top-[310px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-1 h-1 left-[218px] top-[322px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.68px] h-[3.68px] left-[984px] top-[8px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[2.44px] h-[2.44px] left-[832px] top-[55px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.93px] h-[3.93px] left-[1105px] top-[209px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.08px] h-[4.08px] left-[957px] top-[23px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[2.33px] h-[2.33px] left-[1066px] top-[390px] absolute bg-white bg-opacity-80 rounded-full" />
<div className="w-[3.25px] h-[3.25px] left-[737px] top-[118px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.18px] h-[5.18px] left-[202px] top-[19px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.05px] h-[5.05px] left-[466px] top-[17px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.85px] h-[3.85px] left-[144px] top-[153px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.35px] h-[5.35px] left-[233px] top-[330px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-1 h-1 left-[730px] top-[179px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[4.46px] h-[4.46px] left-[1156px] top-[342px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.22px] h-[5.22px] left-[1275px] top-[204px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.50px] h-[5.50px] left-[38px] top-[343px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.14px] h-[5.14px] left-[867px] top-[113px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[2.19px] h-[2.19px] left-[1277px] top-[314px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[3.74px] h-[3.74px] left-[1136px] top-[197px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.37px] h-[5.37px] left-[34px] top-[226px] absolute bg-white bg-opacity-60 rounded-full" />
<div className="w-[5.93px] h-[5.93px] left-[727px] top-[272px] absolute bg-white bg-opacity-50 rounded-full" />
<div className="w-[5.29px] h-[5.29px] left-[277px] top-[43px] absolute bg-white bg-opacity-80 rounded-full" />
</div>
</div>
);
};

View File

@ -0,0 +1,172 @@
import { useCallback, useMemo, useState } from 'react';
import { format } from 'date-fns';
import {
DayPicker,
SelectSingleEventHandler,
DateRange,
} from 'react-day-picker';
import {
Button,
Input,
Popover,
PopoverContent,
PopoverHandler,
} from '@snowballtools/material-tailwind-react-fork';
import HorizontalLine from './HorizontalLine';
// https://www.material-tailwind.com/docs/react/plugins/date-picker#date-picker
const DAY_PICKER_CLASS_NAMES = {
caption: 'flex justify-center py-2 mb-4 relative items-center',
caption_label: 'text-sm font-medium text-gray-900',
nav: 'flex items-center',
nav_button:
'h-6 w-6 bg-transparent hover:bg-blue-gray-50 p-1 rounded-md transition-colors duration-300',
nav_button_previous: 'absolute left-1.5',
nav_button_next: 'absolute right-1.5',
table: 'w-full border-collapse',
head_row: 'flex font-medium text-gray-900',
head_cell: 'm-0.5 w-9 font-normal text-sm',
row: 'flex w-full mt-2',
cell: 'text-gray-600 rounded-md h-9 w-9 text-center text-sm p-0 m-0.5 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-gray-900/20 [&:has([aria-selected].day-outside)]:text-white [&:has([aria-selected])]:bg-gray-900/50 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: 'h-9 w-9 p-0 font-normal',
day_range_end: 'day-range-end',
day_selected:
'rounded-md bg-gray-900 text-white hover:bg-gray-900 hover:text-white focus:bg-gray-900 focus:text-white',
day_today: 'rounded-md bg-gray-200 text-gray-900',
day_outside:
'day-outside text-gray-500 opacity-50 aria-selected:bg-gray-500 aria-selected:text-gray-900 aria-selected:bg-opacity-10',
day_disabled: 'text-gray-500 opacity-50',
day_hidden: 'invisible',
};
type SingleDateHandler = (value: Date) => void;
type RangeDateHandler = (value: DateRange) => void;
interface SingleDatePickerProps {
mode: 'single';
selected?: Date;
onSelect: SingleDateHandler;
}
interface RangeDatePickerProps {
mode: 'range';
selected?: DateRange;
onSelect: RangeDateHandler;
}
const DatePicker = ({
mode = 'single',
selected,
onSelect,
}: SingleDatePickerProps | RangeDatePickerProps) => {
const [isOpen, setIsOpen] = useState(false);
const [rangeSelected, setRangeSelected] = useState<DateRange>();
const inputValue = useMemo(() => {
if (mode === 'single') {
return selected ? format(selected as Date, 'PPP') : 'Select Date';
}
if (mode === 'range') {
const selectedRange = selected as DateRange | undefined;
return selectedRange && selectedRange.from && selectedRange.to
? format(selectedRange.from, 'PP') +
'-' +
format(selectedRange.to, 'PP')
: 'All time';
}
}, [selected, mode]);
const handleSingleSelect = useCallback<SelectSingleEventHandler>((value) => {
if (value) {
(onSelect as SingleDateHandler)(value);
setIsOpen(false);
}
}, []);
const handleRangeSelect = useCallback(() => {
if (rangeSelected?.to) {
(onSelect as RangeDateHandler)(rangeSelected);
setIsOpen(false);
}
}, [rangeSelected]);
const components = {
IconLeft: ({ ...props }) => (
<i {...props} className="h-4 w-4 stroke-2">
{'<'}
</i>
),
IconRight: ({ ...props }) => (
<i {...props} className="h-4 w-4 stroke-2">
{'>'}
</i>
),
};
const commonDayPickerProps = {
components,
className: 'border-0',
classNames: DAY_PICKER_CLASS_NAMES,
showOutsideDays: true,
};
return (
<Popover
placement="bottom"
open={isOpen}
handler={(value) => setIsOpen(value)}
>
<PopoverHandler>
<Input onChange={() => null} value={inputValue} />
</PopoverHandler>
{/* TODO: Figure out what placeholder is for */}
{/* @ts-ignore */}
<PopoverContent>
{mode === 'single' && (
<DayPicker
mode="single"
onSelect={handleSingleSelect}
selected={selected as Date}
{...commonDayPickerProps}
/>
)}
{mode === 'range' && (
<>
<DayPicker
mode="range"
onSelect={setRangeSelected}
selected={rangeSelected as DateRange}
{...commonDayPickerProps}
/>
<HorizontalLine />
<div className="flex justify-end">
{/* TODO: Figure out what placeholder is for */}
<Button
size="sm"
className="rounded-full mr-2"
variant="outlined"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
{/* TODO: Figure out what placeholder is for */}
<Button
size="sm"
className="rounded-full"
color="gray"
onClick={() => handleRangeSelect()}
>
Select
</Button>
</div>
</>
)}
</PopoverContent>
</Popover>
);
};
export default DatePicker;

View File

@ -9,14 +9,6 @@ export interface Option {
label: string;
}
/**
* Props for the Dropdown component.
* @interface DropdownProps
* @property {Option[]} options - The list of options to display in the dropdown.
* @property {(arg: ReactDropdownOption) => void} onChange - Callback fired when an option is selected.
* @property {string} [placeholder] - Placeholder text for the dropdown.
* @property {Option} [value] - The currently selected option.
*/
interface DropdownProps {
options: Option[];
onChange: (arg: ReactDropdownOption) => void;
@ -24,22 +16,6 @@ interface DropdownProps {
value?: Option;
}
/**
* A dropdown component that wraps the ReactDropdown library.
*
* @component
* @param {DropdownProps} props - The props for the Dropdown component.
* @returns {React.ReactElement} A dropdown element.
*
* @example
* ```tsx
* <Dropdown
* options={[{ value: '1', label: 'One' }, { value: '2', label: 'Two' }]}
* onChange={(option) => console.log(option)}
* placeholder="Select an option"
* />
* ```
*/
const Dropdown = ({ placeholder, options, onChange, value }: DropdownProps) => {
return (
<ReactDropdown

View File

@ -1,29 +1,12 @@
import { cn } from '@/utils/classnames';
import { Duration } from 'luxon';
import { ComponentPropsWithoutRef } from 'react';
import { cn } from 'utils/classnames';
/**
* Props for the FormatMillisecond component.
* @interface FormatMilliSecondProps
* @property {number} time - The time in milliseconds to format.
*/
export interface FormatMilliSecondProps
extends ComponentPropsWithoutRef<'div'> {
time: number;
}
/**
* A component that formats a given time in milliseconds into a human-readable format.
*
* @component
* @param {FormatMilliSecondProps} props - The props for the FormatMillisecond component.
* @returns {React.ReactElement} A formatted time element.
*
* @example
* ```tsx
* <FormatMillisecond time={3600000} />
* ```
*/
const FormatMillisecond = ({ time, ...props }: FormatMilliSecondProps) => {
const formatTime = Duration.fromMillis(time)
.shiftTo('days', 'hours', 'minutes', 'seconds')

View File

@ -1,14 +1,3 @@
/**
* A simple horizontal line component.
*
* @component
* @returns {React.ReactElement} A horizontal line element.
*
* @example
* ```tsx
* <HorizontalLine />
* ```
*/
const HorizontalLine = () => {
return <hr className="h-px bg-gray-100 border-0" />;
};

View File

@ -1,26 +1,9 @@
import { Link } from 'react-router-dom';
/**
* Props for the Logo component.
* @interface LogoProps
* @property {string} [orgSlug] - The organization slug used for the link.
*/
interface LogoProps {
orgSlug?: string;
}
/**
* A component that renders the Snowball logo with a link to the organization's page.
*
* @component
* @param {LogoProps} props - The props for the Logo component.
* @returns {React.ReactElement} A logo element.
*
* @example
* ```tsx
* <Logo orgSlug="my-organization" />
* ```
*/
export const Logo = ({ orgSlug }: LogoProps) => {
return (
<Link to={`/${orgSlug}`}>

View File

@ -1,33 +1,21 @@
import React, { forwardRef, RefAttributes } from 'react';
import { IconInput, type IconInputProps } from '@/components/ui/extended/input-w-icons';
import { Search } from 'lucide-react';
import { SearchIcon } from './shared/CustomIcon';
import { Input, InputProps } from './shared/Input';
/**
* A search bar component with an icon input.
*
* @component
* @param {InputProps & RefAttributes<HTMLInputElement>} props - The props for the SearchBar component.
* @returns {React.ReactElement} A search bar element.
*
* @example
* ```tsx
* <SearchBar value="search term" onChange={(e) => console.log(e.target.value)} />
* ```
*/
const SearchBar: React.ForwardRefRenderFunction<
HTMLInputElement,
IconInputProps & RefAttributes<HTMLInputElement>
InputProps & RefAttributes<HTMLInputElement>
> = ({ value, onChange, placeholder = 'Search', ...props }, ref) => {
return (
<div className="relative flex w-full">
<IconInput
leftIcon={<Search className="text-foreground-secondary" />}
<Input
leftIcon={<SearchIcon className="text-foreground-secondary" />}
onChange={onChange}
value={value}
type="search"
placeholder={placeholder}
// appearance="borderless"
appearance="borderless"
className="w-full lg:w-[459px]"
{...props}
ref={ref}

View File

@ -4,42 +4,17 @@ const COLOR_COMPLETED = '#059669';
const COLOR_ACTIVE = '#CFE6FC';
const COLOR_NOT_STARTED = '#F1F5F9';
/**
* Represents a step in the stepper.
* @interface StepperValue
* @property {number} step - The step number.
* @property {string} route - The route associated with the step.
* @property {string} label - The label for the step.
*/
interface StepperValue {
step: number;
route: string;
label: string;
}
/**
* Props for the Stepper component.
* @interface StepperProps
* @property {number} activeStep - The currently active step.
* @property {StepperValue[]} stepperValues - The values for each step.
*/
interface StepperProps {
activeStep: number;
stepperValues: StepperValue[];
}
/**
* A stepper component that displays a series of steps with different states.
*
* @component
* @param {StepperProps} props - The props for the Stepper component.
* @returns {React.ReactElement} A stepper element.
*
* @example
* ```tsx
* <Stepper activeStep={1} stepperValues={[{ step: 1, route: '/step1', label: 'Step 1' }]} />
* ```
*/
const Stepper = ({ activeStep, stepperValues }: StepperProps) => {
return (
<StepperNav

View File

@ -11,29 +11,11 @@ const setStopWatchOffset = (time: string) => {
return currentTime;
};
/**
* Props for the Stopwatch component.
* @interface StopwatchProps
* @property {Date} offsetTimestamp - The initial timestamp for the stopwatch.
* @property {boolean} isPaused - Whether the stopwatch is paused.
*/
interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
offsetTimestamp: Date;
isPaused: boolean;
}
/**
* A stopwatch component that tracks elapsed time.
*
* @component
* @param {StopwatchProps} props - The props for the Stopwatch component.
* @returns {React.ReactElement} A stopwatch element.
*
* @example
* ```tsx
* <Stopwatch offsetTimestamp={new Date()} isPaused={false} />
* ```
*/
const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => {
const { totalSeconds, pause, start } = useStopwatch({
autoStart: true,
@ -47,4 +29,4 @@ const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => {
return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
};
export { setStopWatchOffset, Stopwatch };
export { Stopwatch, setStopWatchOffset };

View File

@ -3,14 +3,6 @@ import * as CSS from 'csstype';
//
// Nav
//
/**
* Describes a step in the stepper navigation.
* @interface IStepDescription
* @property {() => JSX.Element} stepContent - The content of the step.
* @property {string} [stepStateColor] - The color representing the step's state.
* @property {number} [stepStatusCircleSize] - The size of the status circle.
* @property {() => void} [onClickHandler] - Handler for click events on the step.
*/
export interface IStepDescription {
stepContent: () => JSX.Element;
stepStateColor?: string;
@ -18,27 +10,10 @@ export interface IStepDescription {
onClickHandler?: () => void | undefined;
}
/**
* Props for the StepperNav component.
* @interface IStepperNavProps
* @property {IStepDescription[]} steps - The steps to display in the navigation.
*/
export interface IStepperNavProps {
steps: IStepDescription[];
}
/**
* A navigation component for displaying steps in a vertical layout.
*
* @component
* @param {IStepperNavProps} props - The props for the StepperNav component.
* @returns {React.ReactElement} A stepper navigation element.
*
* @example
* ```tsx
* <StepperNav steps={[{ stepContent: () => <div>Step 1</div> }]} />
* ```
*/
export const StepperNav = (props: IStepperNavProps): JSX.Element => {
return (
<nav>

View File

@ -1,76 +0,0 @@
import { ReactNode } from 'react';
import {
Header,
NavigationWrapper,
ScreenWrapper,
TabWrapper,
TabsContent,
TabsList,
TabsTrigger,
} from '../layout';
/**
* Props for the ExamplePage component.
* @interface ExamplePageProps
* @property {ReactNode} [children] - The content to display within the example page.
*/
interface ExamplePageProps {
children?: ReactNode;
}
/**
* An example page component demonstrating the standard layout components.
*
* @component
* @param {ExamplePageProps} props - The props for the ExamplePage component.
* @returns {React.ReactElement} An example page element.
*
* @example
* ```tsx
* <ExamplePage>
* <div>Custom content here</div>
* </ExamplePage>
* ```
*/
export function ExamplePage({ children }: ExamplePageProps) {
return (
<NavigationWrapper>
<ScreenWrapper>
<Header
title="Example Page"
subtitle="Demonstrating the standard layout components"
actions={[
<button key="action" className="btn-primary">
Action
</button>,
]}
/>
<TabWrapper defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Overview</TabsTrigger>
<TabsTrigger value="tab2">Details</TabsTrigger>
<TabsTrigger value="tab3">Settings</TabsTrigger>
</TabsList>
<TabsContent value="tab1">
<div className="p-4">
<h2>Overview Content</h2>
{children}
</div>
</TabsContent>
<TabsContent value="tab2">
<div className="p-4">
<h2>Details Content</h2>
</div>
</TabsContent>
<TabsContent value="tab3">
<div className="p-4">
<h2>Settings Content</h2>
</div>
</TabsContent>
</TabWrapper>
</ScreenWrapper>
</NavigationWrapper>
);
}

View File

@ -1 +0,0 @@
export * from './ui';

View File

@ -1,9 +0,0 @@
export { NavigationWrapper } from './navigation';
export { Header } from './screen-header/Header';
export { ScreenWrapper } from './screen-wrapper/ScreenWrapper';
export { TabWrapper } from './screen-wrapper/TabWrapper';
// Re-export tab components for convenience
export {
TabsContent, TabsList,
TabsTrigger
} from '@/components/ui/tabs';

View File

@ -1,33 +0,0 @@
import { cn } from '@/lib/utils';
import { TopNavigation } from './TopNavigation';
/**
* NavigationWrapperProps interface extends React.HTMLProps<HTMLDivElement> to include all standard HTML div attributes.
*/
interface NavigationWrapperProps extends React.HTMLProps<HTMLDivElement> {}
/**
* Wraps the application's navigation and content within a consistent layout.
* It provides a basic structure with a top navigation bar and a content area.
*
* @param {NavigationWrapperProps} props - The props passed to the NavigationWrapper component.
* @param {React.ReactNode} props.children - The content to be rendered within the wrapper.
* @param {string} [props.className] - Optional CSS class names to apply to the wrapper.
* @param {React.HTMLAttributes<HTMLDivElement>} props.props - Other standard HTML attributes.
*
* @returns {React.ReactElement} A div element containing the top navigation and the children.
*/
export function NavigationWrapper({
children,
className,
...props
}: NavigationWrapperProps) {
// const { isAuthenticated } = useGitHubAuth();
return (
<div className={cn('min-h-screen bg-background', className)} {...props}>
<TopNavigation />
{children}
</div>
);
}

View File

@ -1,283 +0,0 @@
'use client';
import { OnboardingDialog } from '@/components/onboarding-flow';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useGQLClient } from '@/context/GQLClientContext';
import { useOctokit } from '@/context/OctokitContext';
import { useWallet } from '@/context/WalletContextProvider';
import { LaconicMark } from '@/laconic-assets/laconic-mark';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { Organization } from 'gql-client';
import { Menu, Plus, Shapes, Wallet } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ProjectSearchBar } from '../search/ProjectSearchBar';
import { ColorModeToggle } from './components/ColorModeToggle';
import { GitHubSessionButton } from './components/GitHubSessionButton';
import { WalletSessionId } from './components/WalletSessionId';
/**
* TopNavigation Component
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=299-1294&m=dev
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=299-1294&t=LEaCSA9ND6svuP3P-4
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=298-242&t=LEaCSA9ND6svuP3P-4
* https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=299-1294&m=dev
* Renders the top navigation bar, adapting its layout for desktop and mobile views.
* It includes the project search bar, navigation links, user authentication via GitHub,
* color mode toggle, and wallet session information. On mobile, it utilizes a sheet
* for a responsive and accessible navigation experience.
*
* @returns {React.ReactElement} A PopoverPrimitive.Root element containing the top navigation bar.
*/
export function TopNavigation() {
const navigate = useNavigate();
const [showOnboarding, setShowOnboarding] = useState(false);
const { octokit } = useOctokit();
const client = useGQLClient();
const [defaultOrg, setDefaultOrg] = useState<Organization | null>(null);
const { isReady } = useWallet();
// Check if GitHub is connected
const isGitHubConnected = Boolean(octokit);
// Fetch the default organization (first one in the list)
const fetchDefaultOrganization = useCallback(async () => {
try {
const { organizations } = await client.getOrganizations();
if (organizations && organizations.length > 0) {
setDefaultOrg(organizations[0]);
}
} catch (error) {
console.error('Error fetching organizations:', error);
}
}, [client]);
useEffect(() => {
if (isGitHubConnected && isReady) {
fetchDefaultOrganization();
}
}, [isGitHubConnected, fetchDefaultOrganization, isReady]);
const handleOnboardingClose = () => {
setShowOnboarding(false);
// Refresh organization data after onboarding
fetchDefaultOrganization();
};
// Navigate to create page with organization slug
const handleCreateNew = () => {
if (defaultOrg) {
navigate(`/${defaultOrg.slug}/projects/create`);
} else {
// If no organization is available, show onboarding
setShowOnboarding(true);
}
};
const handleRunOnboarding = () => {
// Clear existing onboarding progress data
localStorage.removeItem('onboarding_progress');
localStorage.removeItem('onboarding_state');
// Force starting from connect step
localStorage.setItem('onboarding_force_connect', 'true');
setShowOnboarding(true);
};
return (
<PopoverPrimitive.Root>
<div className="bg-background">
{/* Top Navigation - Desktop */}
<nav className="md:flex items-center justify-between hidden h-16 px-6 border-b">
<div className="flex items-center gap-6">
{/* Logo / Home Link */}
{/* <Button
variant="ghost"
asChild
className="hover:bg-transparent p-0"
> */}
<Link to="/" className="flex items-center justify-center w-10 h-10">
<LaconicMark />
</Link>
{/* </Button> */}
{/* Search Bar */}
{/* <div className="w-96">
<ProjectSearchBar
onChange={(project) => {
navigate(
`/${project.organization.slug}/projects/${project.id}`,
);
}}
/>
</div> */}
{/* Navigation Items */}
<div className="flex items-center gap-4">
<Link to="/deploy-tools/projects">
<Button variant="ghost" asChild className="gap-2">
<Shapes className="w-5 h-5" />
<span>Projects</span>
</Button>
</Link>
<Button
variant="ghost"
asChild
className="text-muted-foreground gap-2"
>
<Link to="/wallet">
<Button asChild variant="ghost">
<Wallet className="w-5 h-5" />
<span>Wallet</span>
</Button>
</Link>
</Button>
</div>
</div>
<div className="flex items-center gap-4">
{/* Add New Button with Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Plus className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isGitHubConnected && (
<>
<DropdownMenuItem onClick={handleCreateNew}>
Create New
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleRunOnboarding}>
Run Onboarding
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* <NavigationActions /> */}
<Button variant="ghost" asChild className="text-muted-foreground">
<Link to="/support">Support</Link>
</Button>
<Button variant="ghost" asChild className="text-muted-foreground">
<Link to="/docs">Documentation</Link>
</Button>
<GitHubSessionButton />
<ColorModeToggle />
<WalletSessionId walletId="0xAb...1234" />
</div>
</nav>
{/* Top Navigation - Mobile */}
<nav className="md:hidden flex items-center justify-between h-16 px-4 border-b">
<Sheet>
<SheetTrigger>
<Button asChild variant="outline" size="icon">
<div>
<Menu className="w-4 h-4" />
</div>
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-[300px] sm:w-[400px] flex flex-col"
>
<nav className="flex flex-col flex-grow space-y-4">
<div className="px-4 py-2">
<ProjectSearchBar
onChange={(project) => {
navigate(
`/${project.organization.slug}/projects/${project.id}`,
);
}}
/>
</div>
<Button variant="ghost" asChild className="justify-start gap-2">
<Link to="/projects">
<Shapes className="w-5 h-5" />
<span>Projects</span>
</Link>
</Button>
<Button variant="ghost" asChild className="justify-start gap-2">
<Link to="/wallet">
<Wallet className="w-5 h-5" />
<span>Wallet</span>
</Link>
</Button>
<Button variant="ghost" asChild className="justify-start">
<Link to="/support">Support</Link>
</Button>
<Button variant="ghost" asChild className="justify-start">
<Link to="/docs">Documentation</Link>
</Button>
<Button
variant="ghost"
className="justify-start"
onClick={handleRunOnboarding}
>
Run Onboarding
</Button>
{isGitHubConnected && (
<Button
variant="ghost"
className="justify-start"
onClick={handleCreateNew}
>
Create New
</Button>
)}
</nav>
<div className="flex items-center justify-between mt-auto">
<GitHubSessionButton />
<ColorModeToggle />
<WalletSessionId walletId="0xAb...1234" />
</div>
</SheetContent>
</Sheet>
<div className="flex items-center gap-2">
{/* Add New Button (Mobile) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Plus className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isGitHubConnected && (
<>
<DropdownMenuItem onClick={handleCreateNew}>
Create New
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleRunOnboarding}>
Run Onboarding
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ColorModeToggle />
<WalletSessionId walletId="0xAb...1234" />
</div>
</nav>
</div>
{/* Onboarding Dialog */}
{showOnboarding && (
<OnboardingDialog defaultOpen={true} onClose={handleOnboardingClose} />
)}
</PopoverPrimitive.Root>
);
}

View File

@ -1,19 +0,0 @@
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
export function ColorModeToggle() {
const { theme, setTheme } = useTheme();
function toggleTheme() {
setTheme(theme === 'dark' ? 'light' : 'dark');
}
return (
<Button variant="outline" size="icon" onClick={toggleTheme}>
<Sun className="h-[1.2rem] w-[1.2rem] transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@ -1,67 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useGitHubAuth } from '@/hooks/useGitHubAuth';
import { Github } from 'lucide-react';
/**
* Renders a button that allows users to log in or log out with their GitHub account.
* It displays the user's avatar and username when logged in, and provides a logout option.
*
* @returns {React.ReactElement} A DropdownMenu element containing the GitHub session button.
*/
export function GitHubSessionButton() {
const { isAuthenticated, user, login, logout } = useGitHubAuth();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className={`relative ${isAuthenticated ? 'text-green-500' : 'text-muted-foreground'}`}
>
<Github className="h-[1.2rem] w-[1.2rem]" />
{isAuthenticated && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-green-500" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" className="w-56" sideOffset={4}>
{isAuthenticated && user ? (
<>
<div className="flex items-center gap-2 p-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar_url} alt={user.login} />
<AvatarFallback>
{user.login.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name || user.login}
</span>
<span className="text-xs text-muted-foreground">
{user.login}
</span>
</div>
</div>
<DropdownMenuItem onClick={logout}>Log out</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={login}>
Log in with GitHub
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}

View File

@ -1,51 +0,0 @@
import type React from 'react';
/**
* LaconicIconProps interface defines the props for the LaconicIcon component.
*/
interface LaconicIconProps {
/**
* Optional CSS class names to apply to the component.
*/
className?: string;
/**
* The width of the icon.
* @default 40
*/
width?: number;
/**
* The height of the icon.
* @default 40
*/
height?: number;
}
/**
* A component that renders the Laconic icon.
*
* @param {LaconicIconProps} props - The props for the component.
* @returns {React.ReactElement} An SVG element representing the Laconic icon.
*/
export const LaconicIcon: React.FC<LaconicIconProps> = ({
className = '',
width = 40,
height = 40,
}) => {
return (
<svg
width={width}
height={height}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.7179 20.6493C14.6275 17.7397 16.4284 13.7219 16.4276 9.28566C16.4288 8.68351 16.3955 8.08808 16.3294 7.5L7.5 7.5008L7.50026 24.4654C7.49946 26.5218 8.28351 28.5788 9.85175 30.1469C11.4201 31.7151 13.4785 32.5001 15.5353 32.4991L32.5 32.5L32.4994 23.6694C31.9126 23.6048 31.3171 23.5713 30.7136 23.5711C26.2786 23.5718 22.2605 25.3725 19.351 28.2819C17.2337 30.346 13.8392 30.3464 11.7483 28.2554C9.65859 26.1656 9.65764 22.7701 11.7179 20.6493ZM30.6686 9.33579C28.2303 6.89759 24.2689 6.89665 21.8298 9.33579C19.3906 11.7748 19.3916 15.7361 21.8298 18.1743C24.2694 20.6138 28.2295 20.6134 30.6686 18.1743C33.1078 15.7353 33.1083 11.7752 30.6686 9.33579Z"
className="fill-current"
/>
</svg>
);
};

View File

@ -1,44 +0,0 @@
import { Button } from '@/components/ui/button';
import { useGQLClient } from '@/context/GQLClientContext';
import { Bell, Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* Renders the navigation actions, including buttons for creating a new project and displaying notifications.
*
* @returns {React.ReactElement} A div element containing the navigation actions.
*/
export function NavigationActions() {
const navigate = useNavigate();
const client = useGQLClient();
/**
* Fetches the organization slug.
* @returns {Promise<string>} The organization slug.
*/
const fetchOrgSlug = useCallback(async () => {
const { organizations } = await client.getOrganizations();
// TODO: Get the selected organization. This is temp
return organizations[0].slug;
}, [client]);
return (
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="icon"
onClick={() => {
fetchOrgSlug().then((organizationSlug) => {
navigate(`/${organizationSlug}/projects/create`);
});
}}
>
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<Bell className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -1,43 +0,0 @@
import { useWallet } from '@/context/WalletContextProvider';
import type React from 'react';
/**
* WalletSessionIdProps interface defines the props for the WalletSessionId component.
*/
interface WalletSessionIdProps {
/**
* The wallet ID to display.
*/
walletId?: string;
/**
* Optional CSS class names to apply to the component.
*/
className?: string;
}
/**
* A component that displays the wallet session ID.
*
* @param {WalletSessionIdProps} props - The props for the component.
* @returns {React.ReactElement} A div element containing the wallet session ID.
*/
export const WalletSessionId: React.FC<WalletSessionIdProps> = ({
walletId,
className = '',
}) => {
const { wallet, isConnected } = useWallet();
// const wallet = { id: 'x123xxx' };
console.log(wallet, 'from WalletSessionId.tsx');
const displayId = wallet?.id || 'Wallet';
return (
<div
className={`flex items-center gap-2 rounded-md bg-secondary px-2.5 py-0.5 ${className}`}
>
<div
className={`h-2 w-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-gray-400'}`}
/>
<span className="text-secondary-foreground text-xs font-semibold">
{displayId}
</span>
</div>
);
};

View File

@ -1,9 +0,0 @@
export { ProjectSearchBar } from '../search/ProjectSearchBar';
export { ColorModeToggle } from './components/ColorModeToggle';
export { GitHubSessionButton } from './components/GitHubSessionButton';
export { LaconicIcon } from './components/LaconicIcon';
export { NavigationActions } from './components/NavigationActions';
export { WalletSessionId } from './components/WalletSessionId';
export { NavigationWrapper } from './NavigationWrapper';
export { TopNavigation } from './TopNavigation';

View File

@ -1,56 +0,0 @@
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import type { LucideIcon } from 'lucide-react';
/**
* ActionButtonProps interface defines the props for the ActionButton component.
*/
interface ActionButtonProps {
/**
* The label of the button.
*/
label: string;
/**
* The icon to display in the button.
*/
icon: LucideIcon;
/**
* The variant of the button.
* @default 'default'
*/
variant?: 'default' | 'outline';
/**
* Callback function to be called when the button is clicked.
*/
onClick?: () => void;
}
/**
* A button component that displays an icon and a label, and triggers an action when clicked.
*
* @param {ActionButtonProps} props - The props for the component.
* @returns {React.ReactElement} A Button element.
*/
export function ActionButton({
label,
icon: Icon,
variant = 'default',
onClick,
}: ActionButtonProps) {
const { toast } = useToast();
const handleClick = () => {
onClick?.();
toast({
title: 'Action Triggered',
description: 'TODO: Connect action',
});
};
return (
<Button variant={variant} onClick={handleClick} className="gap-2">
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</Button>
);
}

View File

@ -1,89 +0,0 @@
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
/**
* HeaderProps interface defines the props for the Header component.
*/
interface HeaderProps {
/**
* The title of the header.
*/
title: string;
/**
* The subtitle of the header.
*/
subtitle?: string;
/**
* An array of actions to display in the header.
*/
actions?: ReactNode[];
/**
* A back button to display in the header.
*/
backButton?: ReactNode;
/**
* Optional CSS class names to apply to the header.
*/
className?: string;
/**
* The layout of the header.
* @default 'default'
*/
layout?: 'default' | 'compact';
}
/**
* A component that renders a header with a title, subtitle, actions, and back button.
*
* @param {HeaderProps} props - The props for the component.
* @returns {React.ReactElement} A div element containing the header content.
*/
export function Header({
title,
subtitle,
actions,
backButton,
className,
layout = 'default',
}: HeaderProps) {
return (
<div
className={cn(
'flex flex-col sm:flex-row sm:items-center sm:justify-between',
layout === 'compact' ? 'mb-2' : 'mb-4',
className,
)}
>
<div className="flex items-center gap-2">
{backButton}
<div>
<h1
className={cn(
'font-bold',
layout === 'compact'
? 'text-lg sm:text-xl'
: 'text-xl sm:text-3xl',
)}
>
{title}
</h1>
{subtitle && (
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
{actions && actions.length > 0 && (
<div
className={cn(
'flex items-center gap-2',
layout === 'default' ? 'mt-4 sm:mt-0' : 'mt-2 sm:mt-0',
)}
>
{actions.map((action, index) => (
<div key={index}>{action}</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,38 +0,0 @@
import { cn } from '@/lib/utils';
/**
* ScreenWrapperProps interface extends React.HTMLProps<HTMLDivElement> to include all standard HTML div attributes.
*/
interface ScreenWrapperProps extends React.HTMLProps<HTMLDivElement> {
/**
* Whether the screen should be padded.
* @default true
*/
padded?: boolean;
}
/**
* A wrapper component for screens, providing consistent padding and layout.
*
* @param {ScreenWrapperProps} props - The props for the component.
* @returns {React.ReactElement} A div element containing the screen content.
*/
export function ScreenWrapper({
children,
className,
// padded = true,
...props
}: ScreenWrapperProps) {
return (
<div
className={cn(
'flex flex-1 flex-col',
'container mx-auto p-4 md:p-6 lg:p-8 ',
className,
)}
{...props}
>
{children}
</div>
);
}

View File

@ -1,43 +0,0 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
/**
* TabWrapperProps interface extends React.ComponentProps<typeof Tabs> to include all standard Tabs attributes.
*/
interface TabWrapperProps extends React.ComponentProps<typeof Tabs> {
/**
* The orientation of the tabs.
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical';
}
/**
* A wrapper component for the Tabs component from `ui/tabs`.
*
* @param {TabWrapperProps} props - The props for the component.
* @returns {React.ReactElement} A Tabs element containing the tabs and content.
*/
export function TabWrapper({
children,
className,
orientation = 'horizontal',
...props
}: TabWrapperProps) {
return (
<Tabs
{...props}
orientation={orientation}
className={cn(
'w-full lg:max-w-2xl mx-auto',
orientation === 'vertical' && 'flex gap-6',
className,
)}
>
{children}
</Tabs>
);
}
// Re-export tab components for convenience
export { TabsContent, TabsList, TabsTrigger };

View File

@ -1,106 +0,0 @@
import { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import { useCallback, useEffect, useState } from 'react';
import { useDebounceValue } from 'usehooks-ts';
import SearchBar from '@/components/SearchBar';
import { useGQLClient } from '@/context/GQLClientContext';
import { cn } from '@/utils/classnames';
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
import { ProjectSearchBarItem } from './ProjectSearchBarItem';
/**
* ProjectSearchBarProps interface defines the props for the ProjectSearchBar component.
*/
interface ProjectSearchBarProps {
/**
* Callback function to be called when a project is selected.
* @param data - The selected project data.
*/
onChange?: (data: Project) => void;
}
/**
* A search bar component that allows the user to search for projects.
*
* @param {ProjectSearchBarProps} props - The props for the component.
* @returns {React.ReactElement} A div element containing the search bar and project list.
*/
export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
const [items, setItems] = useState<Project[]>([]);
const [selectedItem, setSelectedItem] = useState<Project | null>(null);
const client = useGQLClient();
const {
isOpen,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
inputValue,
} = useCombobox({
items,
itemToString(item) {
return item ? item.name : '';
},
selectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
if (newSelectedItem) {
setSelectedItem(newSelectedItem);
if (onChange) {
onChange(newSelectedItem);
}
}
},
});
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300);
const fetchProjects = useCallback(
async (inputValue: string) => {
const { searchProjects } = await client.searchProjects(inputValue);
setItems(searchProjects);
},
[client],
);
useEffect(() => {
if (debouncedInputValue) {
fetchProjects(debouncedInputValue);
}
}, [fetchProjects, debouncedInputValue]);
return (
<div className="relative w-full lg:w-fit dark:bg-overlay">
<SearchBar {...getInputProps()} />
<div
{...getMenuProps({}, { suppressRefError: true })}
className={cn(
'flex flex-col shadow-dropdown rounded-xl dark:bg-overlay2 bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50',
{ hidden: !inputValue || !isOpen },
)}
>
{items.length ? (
<>
<div className="px-2 py-2">
<p className="text-elements-mid-em text-xs font-medium">
Suggestions
</p>
</div>
{items.map((item, index) => (
<ProjectSearchBarItem
{...getItemProps({ item, index })}
key={item.id}
item={item}
active={highlightedIndex === index || selectedItem === item}
/>
))}
</>
) : (
<ProjectSearchBarEmpty />
)}
</div>
</div>
);
};

View File

@ -1,146 +0,0 @@
import { Button } from '@/components/ui/button';
import { IconInput } from '@/components/ui/extended/input-w-icons';
import { useGQLClient } from '@/context/GQLClientContext';
import * as Dialog from '@radix-ui/react-dialog';
import { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import { Search, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDebounceValue } from 'usehooks-ts';
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
import { ProjectSearchBarItem } from './ProjectSearchBarItem';
/**
* ProjectSearchBarDialogProps interface defines the props for the ProjectSearchBarDialog component.
*/
interface ProjectSearchBarDialogProps extends Dialog.DialogProps {
/**
* Whether the dialog is open.
*/
open?: boolean;
/**
* Callback function to be called when the dialog is closed.
*/
onClose?: () => void;
/**
* Callback function to be called when a project item is clicked.
* @param data - The selected project data.
*/
onClickItem?: (data: Project) => void;
}
/**
* A dialog component that allows the user to search for projects.
*
* @param {ProjectSearchBarDialogProps} props - The props for the component.
* @returns {React.ReactElement} A Dialog.Root element containing the search bar and project list.
*/
export const ProjectSearchBarDialog = ({
onClose,
onClickItem,
...props
}: ProjectSearchBarDialogProps) => {
const [items, setItems] = useState<Project[]>([]);
const [selectedItem, setSelectedItem] = useState<Project | null>(null);
const client = useGQLClient();
const navigate = useNavigate();
const {
getInputProps,
getItemProps,
getMenuProps,
inputValue,
setInputValue,
} = useCombobox({
items,
itemToString(item) {
return item ? item.name : '';
},
selectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
if (newSelectedItem) {
setSelectedItem(newSelectedItem);
onClickItem?.(newSelectedItem);
navigate(
`/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
);
}
},
});
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300);
const fetchProjects = useCallback(
async (inputValue: string) => {
const { searchProjects } = await client.searchProjects(inputValue);
setItems(searchProjects);
},
[client],
);
useEffect(() => {
if (debouncedInputValue) {
fetchProjects(debouncedInputValue);
}
}, [fetchProjects, debouncedInputValue]);
const handleClose = () => {
setInputValue('');
setItems([]);
onClose?.();
};
return (
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="bg-base-bg md:hidden fixed inset-0 overflow-y-auto" />
<Dialog.Content>
<div className="fixed inset-0 top-0 flex flex-col h-full">
<div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]">
<IconInput
{...getInputProps({}, { suppressRefError: true })}
leftIcon={<Search />}
placeholder="Search"
className="border-none shadow-none"
autoFocus
/>
<Button size="icon" variant="ghost" onClick={handleClose}>
<X size={16} />
</Button>
</div>
{/* Content */}
<div
className="flex flex-col gap-1 px-2 py-2"
{...getMenuProps(
{},
{
suppressRefError: true,
},
)}
>
{items.length > 0 ? (
<>
<div className="px-2 py-2">
<p className="text-elements-mid-em text-xs font-medium">
Suggestions
</p>
</div>
{items.map((item, index) => (
<ProjectSearchBarItem
key={item.id}
item={item}
{...getItemProps({ item, index })}
/>
))}
</>
) : (
inputValue && <ProjectSearchBarEmpty />
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@ -1,33 +0,0 @@
import { cn } from '@/utils/classnames';
import { Search } from 'lucide-react';
import { ComponentPropsWithoutRef } from 'react';
/**
* ProjectSearchBarEmptyProps interface defines the props for the ProjectSearchBarEmpty component.
*/
interface ProjectSearchBarEmptyProps extends ComponentPropsWithoutRef<'div'> {}
/**
* A component that renders a message when no projects match the search query.
*
* @param {ProjectSearchBarEmptyProps} props - The props for the component.
* @returns {React.ReactElement} A div element displaying a "no projects found" message.
*/
export const ProjectSearchBarEmpty = ({
className,
...props
}: ProjectSearchBarEmptyProps) => {
return (
<div
{...props}
className={cn('flex items-center px-2 py-2 gap-3', className)}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-orange-50 text-elements-warning dark:bg-red-50 text-error">
<Search size={16} />
</div>
<p className="text-elements-low-em text-sm dark:text-foreground-secondary tracking-[-0.006em]">
No projects matching this name
</p>
</div>
);
};

View File

@ -1,75 +0,0 @@
import { Avatar } from '@/components/shared/Avatar';
import { OmitCommon } from '@/types/common';
import { cn } from '@/utils/classnames';
import { Overwrite, UseComboboxGetItemPropsReturnValue } from 'downshift';
import { Project } from 'gql-client';
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { getInitials } from '../../../../utils/geInitials';
/**
* Represents a type that merges ComponentPropsWithoutRef<'li'> with certain exclusions.
* @type {MergedComponentPropsWithoutRef}
*/
type MergedComponentPropsWithoutRef = OmitCommon<
ComponentPropsWithoutRef<'button'>,
Omit<
Overwrite<UseComboboxGetItemPropsReturnValue, Project[]>,
'index' | 'item'
>
>;
/**
* ProjectSearchBarItemProps interface defines the props for the ProjectSearchBarItem component.
*/
interface ProjectSearchBarItemProps extends MergedComponentPropsWithoutRef {
/**
* The project item to display.
*/
item: Project;
/**
* Whether the item is active.
*/
active?: boolean;
}
/**
* A component that renders a project item in the search bar.
*
* @param {ProjectSearchBarItemProps} props - The props for the component.
* @returns {React.ReactElement} A button element representing a project item.
*/
const ProjectSearchBarItem = forwardRef<
HTMLButtonElement,
ProjectSearchBarItemProps
>(({ item, active, ...props }, ref) => {
return (
<button
{...props}
ref={ref}
key={item.id}
className={cn(
'px-2 py-2 flex items-center gap-3 rounded-lg text-left hover:bg-base-bg-emphasized',
{
'bg-base-bg-emphasized': active,
},
)}
>
<Avatar
size={32}
imageSrc={item.icon}
initials={getInitials(item.name)}
/>
<div className="flex flex-col flex-1">
<p className="text-sm tracking-[-0.006em] text-elements-high-em">
{item.name}
</p>
<p className="text-elements-low-em text-xs">{item.organization.name}</p>
</div>
</button>
);
});
ProjectSearchBarItem.displayName = 'ProjectSearchBarItem';
export { ProjectSearchBarItem };

View File

@ -1,2 +0,0 @@
export * from './ProjectSearchBar';
export * from './ProjectSearchBarDialog';

View File

@ -1,83 +0,0 @@
'use client';
import { LaconicMark } from '@/laconic-assets/laconic-mark';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
export interface LoadingOverlayProps {
/**
* Controls the visibility of the overlay.
* When false, the component returns null.
* @default true
*/
isLoading?: boolean;
/**
* Optional className for styling the overlay container.
* This will be merged with the default styles.
*/
className?: string;
/**
* Whether to show the Laconic logo in the overlay.
* @default true
*/
showLogo?: boolean;
/**
* Whether to show the loading spinner below the logo.
* @default true
*/
showSpinner?: boolean;
/**
* The z-index value for the overlay.
* Adjust this if you need the overlay to appear above or below other elements.
* @default 50
*/
zIndex?: number;
/**
* Whether to use solid black background instead of semi-transparent.
* Useful for initial page load and full-screen loading states.
* @default false
*/
solid?: boolean;
}
export function LoadingOverlay({
isLoading = true,
className,
showLogo = true,
showSpinner = true,
zIndex = 50,
solid = false,
}: LoadingOverlayProps) {
if (!isLoading) return null;
return (
<div
className={cn(
'fixed inset-0 flex flex-col items-center justify-center',
solid ? 'bg-black' : 'bg-black/90 backdrop-blur-sm',
className,
)}
style={{ zIndex }}
role="alert"
aria-busy="true"
aria-label="Loading"
>
{showLogo && (
<div className="animate-fade-in mb-4">
<LaconicMark className="text-white" />
</div>
)}
{showSpinner && (
<Loader2
className="animate-spin w-6 h-6 text-white"
aria-hidden="true"
/>
)}
</div>
);
}

View File

@ -1,110 +0,0 @@
/**
* NavigationSidebar.tsx
*
* This component displays a sidebar with navigation steps for the onboarding flow.
* It highlights the current step and allows navigation to completed steps.
*
* Implementation:
* 1. Display the logo at the top
* 2. Show a list of navigation items for each step
* 3. Highlight the current step
* 4. Make completed steps clickable for navigation
* 5. Style according to the shadcn/ui design system
*/
import { cn } from '@/lib/utils';
import {
Code,
GitBranch,
Github,
Rocket,
Server,
Settings
} from 'lucide-react';
import React from 'react';
import useNavigationStore, { OnboardingStep } from './store/navigationStore';
// Map steps to icons and labels
const stepConfig = {
[OnboardingStep.CONNECT]: {
icon: Github,
label: 'Connect'
},
[OnboardingStep.REPOSITORY]: {
icon: GitBranch,
label: 'Repository'
},
[OnboardingStep.TEMPLATE]: {
icon: Code,
label: 'Template'
},
[OnboardingStep.CONFIGURE]: {
icon: Settings,
label: 'Configure'
},
[OnboardingStep.DEPLOYMENT_OPTIONS]: {
icon: Server,
label: 'Options'
},
[OnboardingStep.DEPLOY]: {
icon: Rocket,
label: 'Deploy'
},
};
const NavigationSidebar: React.FC = () => {
const { currentStep, completedSteps, setCurrentStep, canGoToStep } = useNavigationStore();
// Order of steps to display
const steps = [
OnboardingStep.CONNECT,
OnboardingStep.REPOSITORY,
OnboardingStep.TEMPLATE,
OnboardingStep.CONFIGURE,
OnboardingStep.DEPLOYMENT_OPTIONS,
OnboardingStep.DEPLOY,
];
return (
<div className="w-64 h-full bg-background border-r p-4 flex flex-col">
{/* Logo */}snowballtools-base
<div className="mb-8 p-2">
<h1 className="text-xl font-bold">Snowball</h1>
</div>
{/* Navigation Items */}
<nav className="space-y-2">
{steps.map((step) => {
const { icon: Icon, label } = stepConfig[step];
const isActive = currentStep === step;
const isCompleted = completedSteps[step];
const isClickable = canGoToStep(step);
return (
<button
key={step}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive && "bg-primary text-primary-foreground",
!isActive && isCompleted && "text-foreground hover:bg-muted/80",
!isActive && !isCompleted && "text-muted-foreground",
!isClickable && "opacity-50 cursor-not-allowed",
isClickable && !isActive && "hover:bg-muted cursor-pointer"
)}
onClick={() => isClickable && setCurrentStep(step)}
disabled={!isClickable}
>
<Icon size={18} />
<span>{label}</span>
{isCompleted && !isActive && (
<span className="ml-auto w-2 h-2 bg-primary rounded-full" />
)}
</button>
);
})}
</nav>
</div>
);
};
export default NavigationSidebar;

View File

@ -1,36 +0,0 @@
import {
Dialog,
DialogContent, DialogTrigger
} from '@/components/ui/dialog';
import React from 'react';
import Onboarding from '../onboarding-flow/Onboarding';
interface OnboardingDialogProps {
trigger?: React.ReactNode;
defaultOpen?: boolean;
}
/**
* OnboardingDialog component
*
* A dialog modal that contains the onboarding flow.
* Can be triggered by a custom element or automatically opened.
*/
const OnboardingDialog: React.FC<OnboardingDialogProps> = ({
trigger,
defaultOpen = false
}) => {
return (
<Dialog defaultOpen={defaultOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-[95vw] max-h-[95vh] w-[1200px] h-[800px] overflow-hidden p-0">
<div className="h-full overflow-hidden">
{/* <OnboardingLayout isDialog={true} /> */}
<Onboarding/>
</div>
</DialogContent>
</Dialog>
);
};
export default OnboardingDialog;

View File

@ -1,88 +0,0 @@
/**
* OnboardingLayout.tsx
*
* This component serves as the main wrapper for the entire onboarding flow.
* It handles the layout structure with a sidebar and main content area.
* Modified to work well in both full page and dialog contexts.
*
* Implementation:
* 1. Use the existing layout components where possible
* 2. Render the NavigationSidebar on the left
* 3. Render the current step component in the main content area
* 4. The current step should be determined by the navigation store
* 5. Add proper spacing and responsive behavior
*
* Dependencies:
* - NavigationSidebar
* - ConnectView
* - RepositoryListView
* - TemplateSelectionView
* - ConfigureView
* - DeploymentOptionsView
* - DeployView
* - useNavigationStore
*/
import React from 'react';
import NavigationSidebar from './NavigationSidebar';
import ProgressIndicator from './ProgressIndicator';
import useNavigationStore, { OnboardingStep } from './store/navigationStore';
// Import view components
import ConfigureView from './views/ConfigureView';
import ConnectView from './views/ConnectView';
import DeploymentOptionsView from './views/DeploymentOptionsView';
import DeployView from './views/DeployView';
import RepositoryListView from './views/RepositoryListView';
import TemplateSelectionView from './views/TemplateSelectionView';
interface OnboardingLayoutProps {
isDialog?: boolean;
}
const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
isDialog = false
}) => {
const { currentStep } = useNavigationStore();
// Render the appropriate view component based on the current step
const renderCurrentView = () => {
switch (currentStep) {
case OnboardingStep.CONNECT:
return <ConnectView />;
case OnboardingStep.REPOSITORY:
return <RepositoryListView />;
case OnboardingStep.TEMPLATE:
return <TemplateSelectionView />;
case OnboardingStep.CONFIGURE:
return <ConfigureView />;
case OnboardingStep.DEPLOYMENT_OPTIONS:
return <DeploymentOptionsView />;
case OnboardingStep.DEPLOY:
return <DeployView />;
default:
return <ConnectView />;
}
};
return (
<div className={`flex ${isDialog ? 'h-full' : 'h-screen'} w-full bg-background`}>
{/* Navigation Sidebar */}
<NavigationSidebar />
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto p-6">
{renderCurrentView()}
</main>
{/* Progress Indicator Footer */}
<footer className="border-t p-4">
<ProgressIndicator />
</footer>
</div>
</div>
);
};
export default OnboardingLayout;

View File

@ -1,60 +0,0 @@
/**
* ProgressIndicator.tsx
*
* This component displays a horizontal indicator showing the current progress
* through the onboarding flow steps.
*
* Implementation:
* 1. Use dots to represent each step
* 2. Highlight the current step
* 3. Show completed steps differently
* 4. Style according to the shadcn/ui design system
*/
import { cn } from '@/lib/utils';
import React from 'react';
import useNavigationStore, { OnboardingStep } from './store/navigationStore';
const ProgressIndicator: React.FC = () => {
const { currentStep, completedSteps } = useNavigationStore();
// Define the steps to show in the progress indicator
const steps = [
OnboardingStep.CONNECT,
OnboardingStep.REPOSITORY,
OnboardingStep.TEMPLATE,
OnboardingStep.CONFIGURE,
OnboardingStep.DEPLOYMENT_OPTIONS,
OnboardingStep.DEPLOY,
];
return (
<div className="flex items-center justify-center space-x-2 py-4">
{steps.map((step, index) => {
const isActive = currentStep === step;
const isCompleted = completedSteps[step];
return (
<div
key={step}
className="flex flex-col items-center"
>
<div
className={cn(
"w-3 h-3 rounded-full transition-all duration-200",
isActive && "w-4 h-4 bg-primary",
isCompleted && !isActive && "bg-primary opacity-70",
!isActive && !isCompleted && "bg-muted"
)}
/>
{index < steps.length - 1 && (
<div className="w-8 h-0.5 bg-muted mt-1.5" />
)}
</div>
);
})}
</div>
);
};
export default ProgressIndicator;

View File

@ -1,31 +0,0 @@
# Onboarding Flow
This directory contains the implementation of the onboarding flow for the application.
## Documentation
Please refer to the following documentation for details about the onboarding flow:
- [Component Breakdown](./docs/Component-Breakdown.md) - Overview of all components used in the onboarding flow
- [State Management](./docs/State-Management.md) - Details about Zustand store implementations for state management
- [Form Integration Plan](./docs/Form-Integration-Plan.md) - Guidelines for integrating existing form components
## Implementation Approach
The onboarding flow uses a navigation-only state management approach with Zustand to coordinate between steps, while preserving the functionality of existing form components.
## Directory Structure
- `/docs` - Documentation files
- `/store` - Zustand store implementation
- `/views` - Main view components for each step of the flow
## Getting Started
To implement the onboarding flow, follow these steps:
1. Review the documentation in the `/docs` directory
2. Implement the navigation store as described in the State Management document
3. Create the core layout components
4. Implement each view component according to the Form Integration Plan
5. Test the flow with existing form components

View File

@ -1,24 +0,0 @@
/**
* index.ts
*
* This file exports all components from the onboarding module for easy importing.
*/
// Export the main layout
export { default as OnboardingLayout } from './OnboardingLayout';
// Export the navigation store
export { default as useNavigationStore } from './store/navigationStore';
export { OnboardingStep } from './store/navigationStore';
// Export view components
export { default as ConnectView } from './views/ConnectView';
export { default as RepositoryListView } from './views/RepositoryListView';
export { default as TemplateSelectionView } from './views/TemplateSelectionView';
export { default as ConfigureView } from './views/ConfigureView';
export { default as DeploymentOptionsView } from './views/DeploymentOptionsView';
export { default as DeployView } from './views/DeployView';
// Export other components
export { default as NavigationSidebar } from './NavigationSidebar';
export { default as ProgressIndicator } from './ProgressIndicator';

View File

@ -1,175 +0,0 @@
/**
* navigationStore.ts
*
* This file implements the Zustand store for navigation state management.
* It focuses on navigation-only state to coordinate between steps.
*
* Implementation:
* 1. Define the OnboardingStep enum for all steps
* 2. Create a NavigationState interface with:
* - currentStep tracking
* - completedSteps status object
* - navigation actions (setCurrentStep, markStepCompleted, etc.)
* 3. Implement the store with Zustand
* 4. Add persistence with localStorage
* 5. Implement navigation logic (can only go to completed steps or next incomplete)
* 6. Add actions for resetting navigation
*
* See the State-Management.md documentation for detailed guidance.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Define the steps of the onboarding flow
export enum OnboardingStep {
CONNECT = 'connect',
REPOSITORY = 'repository',
TEMPLATE = 'template',
CONFIGURE = 'configure',
DEPLOYMENT_OPTIONS = 'deployment_options',
DEPLOY = 'deploy'
}
// Define the navigation state interface
interface NavigationState {
// Current step in the flow
currentStep: OnboardingStep;
// Step completion status
completedSteps: {
[key in OnboardingStep]: boolean;
};
// Navigation actions
setCurrentStep: (step: OnboardingStep) => void;
markStepCompleted: (step: OnboardingStep) => void;
markStepIncomplete: (step: OnboardingStep) => void;
goToNextStep: () => void;
goToPreviousStep: () => void;
resetNavigation: () => void;
canGoToStep: (step: OnboardingStep) => boolean;
}
// Order of steps for navigation
const stepOrder: OnboardingStep[] = [
OnboardingStep.CONNECT,
OnboardingStep.REPOSITORY,
OnboardingStep.TEMPLATE,
OnboardingStep.CONFIGURE,
OnboardingStep.DEPLOYMENT_OPTIONS,
OnboardingStep.DEPLOY,
];
// Create and export the store
const useNavigationStore = create<NavigationState>()(
persist(
(set, get) => ({
// Initial state
currentStep: OnboardingStep.CONNECT,
completedSteps: {
[OnboardingStep.CONNECT]: false,
[OnboardingStep.REPOSITORY]: false,
[OnboardingStep.TEMPLATE]: false,
[OnboardingStep.CONFIGURE]: false,
[OnboardingStep.DEPLOYMENT_OPTIONS]: false,
[OnboardingStep.DEPLOY]: false,
},
// Set current step if allowed
setCurrentStep: (step: OnboardingStep) => {
const { canGoToStep } = get();
if (canGoToStep(step)) {
set({ currentStep: step });
}
},
// Mark a step as completed
markStepCompleted: (step: OnboardingStep) => {
set((state) => ({
completedSteps: {
...state.completedSteps,
[step]: true,
},
}));
},
// Mark a step as incomplete
markStepIncomplete: (step: OnboardingStep) => {
set((state) => ({
completedSteps: {
...state.completedSteps,
[step]: false,
},
}));
},
// Go to the next step in the flow
goToNextStep: () => {
const { currentStep } = get();
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex < stepOrder.length - 1) {
const nextStep = stepOrder[currentIndex + 1];
set({ currentStep: nextStep });
}
},
// Go to the previous step in the flow
goToPreviousStep: () => {
const { currentStep } = get();
const currentIndex = stepOrder.indexOf(currentStep);
if (currentIndex > 0) {
const prevStep = stepOrder[currentIndex - 1];
set({ currentStep: prevStep });
}
},
// Reset navigation state
resetNavigation: () => {
set({
currentStep: OnboardingStep.CONNECT,
completedSteps: {
[OnboardingStep.CONNECT]: false,
[OnboardingStep.REPOSITORY]: false,
[OnboardingStep.TEMPLATE]: false,
[OnboardingStep.CONFIGURE]: false,
[OnboardingStep.DEPLOYMENT_OPTIONS]: false,
[OnboardingStep.DEPLOY]: false,
},
});
},
// Check if navigation to a step is allowed
canGoToStep: (step: OnboardingStep) => {
const { completedSteps } = get();
const stepIndex = stepOrder.indexOf(step);
const currentStepIndex = stepOrder.indexOf(get().currentStep);
// Can always go to current step or previous completed steps
if (step === get().currentStep || (stepIndex < currentStepIndex && completedSteps[step])) {
return true;
}
// Can go to the next step if all previous steps are completed
if (stepIndex === currentStepIndex + 1) {
// Check if all previous steps are completed
for (let i = 0; i < stepIndex; i++) {
if (!completedSteps[stepOrder[i]]) {
return false;
}
}
return true;
}
return false;
},
}),
{
name: 'onboarding-navigation-storage',
}
)
);
export default useNavigationStore;

View File

@ -1,163 +0,0 @@
/**
* ConfigureView.tsx
*
* This component displays configuration options for deployment.
* It includes settings for deployment URL, environment variables, and other options.
*
* Implementation:
* 1. Display form sections for various configuration options
* 2. Include input fields for deployment URL
* 3. Add environment variables section
* 4. Include deployment options (number of instances, etc.)
* 5. Add navigation controls
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Plus, Settings } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
const ConfigureView: React.FC = () => {
const [deploymentUrl, setDeploymentUrl] = useState('');
const [maxDeployments, setMaxDeployments] = useState('');
const [environmentTypes, setEnvironmentTypes] = useState({
production: true,
preview: true,
development: false,
});
const { markStepCompleted, goToNextStep, goToPreviousStep } = useNavigationStore();
const handleNext = () => {
// In a real app, would validate inputs
markStepCompleted(OnboardingStep.CONFIGURE);
goToNextStep();
};
const toggleEnvironmentType = (type: 'production' | 'preview' | 'development') => {
setEnvironmentTypes({
...environmentTypes,
[type]: !environmentTypes[type],
});
};
return (
<div className="flex flex-col max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Configure</h1>
<p className="text-lg text-muted-foreground">
Set the deployment URL for a single deployment or by creating a separate section for multiple deployments
</p>
</div>
<div className="flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<Settings size={32} className="text-primary" />
</div>
<h2 className="text-2xl font-semibold mb-4">Configure</h2>
</div>
{/* Configuration Form */}
<Card>
<CardHeader>
<CardTitle>Deployment Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Deployment URL */}
<div className="space-y-2">
<Label htmlFor="deployment-url">Deployment URL</Label>
<Input
id="deployment-url"
placeholder="my-app.example.com"
value={deploymentUrl}
onChange={(e) => setDeploymentUrl(e.target.value)}
/>
</div>
{/* Number of Deployments */}
<div className="space-y-2">
<Label htmlFor="max-deployments">Number of Deployments</Label>
<Select value={maxDeployments} onValueChange={setMaxDeployments}>
<SelectTrigger id="max-deployments">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
</SelectContent>
</Select>
</div>
{/* Environment Variables */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label>Environment Variables</Label>
<Button variant="ghost" size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Variable
</Button>
</div>
{/* Environment variables would be implemented here */}
</div>
{/* Environment Types */}
<div className="space-y-2">
<Label>Environment Types</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="production"
checked={environmentTypes.production}
onCheckedChange={() => toggleEnvironmentType('production')}
/>
<Label htmlFor="production" className="cursor-pointer">Production</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="preview"
checked={environmentTypes.preview}
onCheckedChange={() => toggleEnvironmentType('preview')}
/>
<Label htmlFor="preview" className="cursor-pointer">Preview</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="development"
checked={environmentTypes.development}
onCheckedChange={() => toggleEnvironmentType('development')}
/>
<Label htmlFor="development" className="cursor-pointer">Development</Label>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<Button onClick={handleNext}>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
export default ConfigureView;

View File

@ -1,143 +0,0 @@
/**
* ConnectView.tsx
*
* This component displays the initial connection screen for GitHub authentication.
* It shows a button to connect with GitHub and options to select import or template after connection.
*
* Implementation:
* 1. Use existing Card components from shared/ui
* 2. Add a GitHub connect button with icon
* 3. Show two card options after connection: Import Repository or Start with Template
* 4. Add selection behavior for the two options
* 5. Show a "Next" button that's enabled when an option is selected
* 6. Use navigation store to mark step as completed and go to next step
* 7. Match the dark theme UI shown in the screenshots
*
* Dependencies:
* - Card, CardHeader, CardContent, CardFooter from shared components
* - Button from shared components
* - Github icon from Lucide React
* - useNavigationStore
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowRight, Code, GitBranch, Github } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
const ConnectView: React.FC = () => {
const [isConnected, setIsConnected] = useState(false);
const [selectedOption, setSelectedOption] = useState<'import' | 'template' | null>(null);
const { markStepCompleted, goToNextStep, setCurrentStep } = useNavigationStore();
// Function to simulate GitHub connection
const handleConnect = () => {
// In a real app, this would trigger GitHub OAuth
setIsConnected(true);
// Mark the connect step as completed
markStepCompleted(OnboardingStep.CONNECT);
};
// Function to handle option selection
const handleOptionSelect = (option: 'import' | 'template') => {
setSelectedOption(option);
};
// Function to handle navigation to next step
const handleNext = () => {
if (selectedOption === 'import') {
setCurrentStep(OnboardingStep.REPOSITORY);
} else if (selectedOption === 'template') {
setCurrentStep(OnboardingStep.TEMPLATE);
}
};
return (
<div className="flex flex-col items-center max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Connect</h1>
<p className="text-lg text-muted-foreground">
Connect and import a GitHub repo or start from a template
</p>
</div>
{!isConnected ? (
// Not connected state - Show GitHub connect
<div className="flex flex-col items-center space-y-6 w-full max-w-md">
<div className="flex justify-center p-6">
<div className="w-24 h-24 flex items-center justify-center rounded-full bg-primary/10">
<Github size={48} className="text-primary" />
</div>
</div>
<h2 className="text-2xl font-semibold text-center">Deploy your first app</h2>
<p className="text-center text-muted-foreground">
Once connected, you can import a repository from your account or start with one of our templates.
</p>
<Button onClick={handleConnect} size="lg" className="mt-4">
<Github className="mr-2 h-5 w-5" />
Connect to GitHub
</Button>
</div>
) : (
// Connected state - Show import/template options
<div className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* Import Repository Option */}
<Card
className={`cursor-pointer border-2 transition-colors ${
selectedOption === 'import' ? 'border-primary' : 'border-border'
}`}
onClick={() => handleOptionSelect('import')}
>
<CardContent className="p-6 flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<GitBranch size={32} className="text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2">Import a repository</h3>
<p className="text-center text-muted-foreground">
Select from your existing GitHub repositories
</p>
</CardContent>
</Card>
{/* Start with Template Option */}
<Card
className={`cursor-pointer border-2 transition-colors ${
selectedOption === 'template' ? 'border-primary' : 'border-border'
}`}
onClick={() => handleOptionSelect('template')}
>
<CardContent className="p-6 flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<Code size={32} className="text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2">Start with a template</h3>
<p className="text-center text-muted-foreground">
Choose from our pre-configured templates
</p>
</CardContent>
</Card>
</div>
{/* Navigation Buttons */}
<div className="flex justify-end">
<Button
onClick={handleNext}
disabled={!selectedOption}
className="px-6"
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
};
export default ConnectView;

View File

@ -1,126 +0,0 @@
/**
* DeployView.tsx
*
* This component displays the final deployment screen with status information
* and payment options.
*
* Implementation:
* 1. Display deployment status with confirmations
* 2. Show project details
* 3. Include payment or deployment button
* 4. Add navigation controls
* 5. Match the dark theme UI shown in the screenshots
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, ArrowRight, Check, Rocket } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
const DeployView: React.FC = () => {
const [isDeployed, setIsDeployed] = useState(false);
const { markStepCompleted, goToPreviousStep } = useNavigationStore();
const handleDeploy = () => {
// In a real app, this would actually deploy the project
setIsDeployed(true);
markStepCompleted(OnboardingStep.DEPLOY);
};
// Mock project data
const project = {
name: 'Progressive Web App (PWA)',
url: 'git.account/repo-name',
status: 'configured',
};
return (
<div className="flex flex-col max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Deploy</h1>
<p className="text-lg text-muted-foreground">
Your deployment is configured and ready to go!
</p>
</div>
<div className="flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<Rocket size={32} className="text-primary" />
</div>
<h2 className="text-2xl font-semibold mb-4">Deploy</h2>
</div>
{/* Deployment Status Card */}
<Card className="border border-border">
<CardContent className="p-6">
<div className="text-center mb-6">
<h3 className="text-xl font-medium mb-2">
{isDeployed ? 'Deployment Complete!' : 'Your deployment is configured and ready to go!'}
</h3>
<p className="text-muted-foreground">
{isDeployed
? 'Your app has been successfully deployed and is now live.'
: 'Review your settings and click "Pay and Deploy" to launch your application.'}
</p>
</div>
{/* Project Details */}
<div className="space-y-4 mb-6">
<div className="flex justify-between items-center p-3 bg-muted/50 rounded-md">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="font-medium">Progressive Web App (PWA)</span>
</div>
<span className="text-sm text-muted-foreground">$7 per month</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground p-2">
<Check size={16} className="text-green-500" />
<span>git.account/repo-name</span>
</div>
</div>
{/* Deploy Button */}
{!isDeployed ? (
<Button
className="w-full"
size="lg"
onClick={handleDeploy}
>
Pay and Deploy
</Button>
) : (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-green-500">
<Check size={20} />
<span className="font-medium">Successfully Deployed</span>
</div>
<Button variant="outline" className="w-full">
View Dashboard
</Button>
</div>
)}
</CardContent>
</Card>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
{isDeployed && (
<Button variant="default">
Finish
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
);
};
export default DeployView;

View File

@ -1,151 +0,0 @@
/**
* DeploymentOptionsView.tsx
*
* This component displays advanced deployment options like container selection,
* account selection, and other deployment-specific settings.
*
* Implementation:
* 1. Display forms for deployment options
* 2. Include dropdown for container URL selection
* 3. Include account selection
* 4. Add navigation controls
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Server } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
const DeploymentOptionsView: React.FC = () => {
const [selectedContainer, setSelectedContainer] = useState('');
const [selectedAccount, setSelectedAccount] = useState('');
const [environmentTypes, setEnvironmentTypes] = useState({
production: true,
preview: true,
development: false,
});
const { markStepCompleted, goToNextStep, goToPreviousStep } = useNavigationStore();
const handleNext = () => {
// In a real app, would validate inputs
markStepCompleted(OnboardingStep.DEPLOYMENT_OPTIONS);
goToNextStep();
};
const toggleEnvironmentType = (type: 'production' | 'preview' | 'development') => {
setEnvironmentTypes({
...environmentTypes,
[type]: !environmentTypes[type],
});
};
return (
<div className="flex flex-col max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Configure</h1>
<p className="text-lg text-muted-foreground">
Set the deployment URL for a single deployment or by creating a separate section for multiple deployments
</p>
</div>
<div className="flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<Server size={32} className="text-primary" />
</div>
<h2 className="text-2xl font-semibold mb-4">Deployment Options</h2>
</div>
{/* Deployment Options Form */}
<Card>
<CardHeader>
<CardTitle>Container Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Container URL */}
<div className="space-y-2">
<Label htmlFor="container-url">Select Container URL</Label>
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
<SelectTrigger id="container-url">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="container1">container-123.example.com</SelectItem>
<SelectItem value="container2">container-456.example.com</SelectItem>
<SelectItem value="container3">container-789.example.com</SelectItem>
</SelectContent>
</Select>
</div>
{/* Environment Types */}
<div className="space-y-2">
<Label>Environment Variables</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="production-option"
checked={environmentTypes.production}
onCheckedChange={() => toggleEnvironmentType('production')}
/>
<Label htmlFor="production-option" className="cursor-pointer">Production</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="preview-option"
checked={environmentTypes.preview}
onCheckedChange={() => toggleEnvironmentType('preview')}
/>
<Label htmlFor="preview-option" className="cursor-pointer">Preview</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="development-option"
checked={environmentTypes.development}
onCheckedChange={() => toggleEnvironmentType('development')}
/>
<Label htmlFor="development-option" className="cursor-pointer">Development</Label>
</div>
</div>
</div>
{/* Account Selection */}
<div className="space-y-2">
<Label htmlFor="select-account">Select Account</Label>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger id="select-account">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="account1">Personal Account</SelectItem>
<SelectItem value="account2">Team Account</SelectItem>
<SelectItem value="account3">Organization Account</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<Button onClick={handleNext}>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
export default DeploymentOptionsView;

View File

@ -1,106 +0,0 @@
/**
* RepositoryListView.tsx
*
* This component displays a list of GitHub repositories for the user to select from.
* It shows repository names with timestamps and allows selection.
*
* Implementation:
* 1. Use a scrollable list component
* 2. Show repository names with timestamps
* 3. Allow selection with radio buttons
* 4. Add navigation controls (back/next)
* 5. Add proper spacing and responsive behavior
*/
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ArrowLeft, ArrowRight, GitBranch } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
// Mock data for repositories
const mockRepositories = [
{ id: '1', name: 'username/repo-name', updatedAt: '4 minutes ago' },
{ id: '2', name: 'username/another-repo', updatedAt: '6 minutes ago' },
{ id: '3', name: 'username/test-project', updatedAt: '2 hours ago' },
{ id: '4', name: 'username/awesome-app', updatedAt: '1 day ago' },
{ id: '5', name: 'username/frontend-demo', updatedAt: '3 days ago' },
];
const RepositoryListView: React.FC = () => {
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const { markStepCompleted, goToNextStep, goToPreviousStep } = useNavigationStore();
const handleRepoSelect = (repoId: string) => {
setSelectedRepo(repoId);
};
const handleNext = () => {
if (selectedRepo) {
markStepCompleted(OnboardingStep.REPOSITORY);
goToNextStep();
}
};
return (
<div className="flex flex-col max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Connect</h1>
<p className="text-lg text-muted-foreground">
Connect and import a GitHub repo or start from a template
</p>
</div>
<div className="flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<GitBranch size={32} className="text-primary" />
</div>
<h2 className="text-2xl font-semibold mb-4">Import a repository</h2>
</div>
{/* Repository List */}
<div className="border rounded-lg w-full">
<ScrollArea className="h-64">
<RadioGroup value={selectedRepo || ''} onValueChange={handleRepoSelect} className="p-1">
{mockRepositories.map((repo) => (
<div
key={repo.id}
className="flex items-center space-x-3 border-b last:border-0 p-4 hover:bg-muted/50 transition-colors"
>
<RadioGroupItem value={repo.id} id={`repo-${repo.id}`} className="data-[state=checked]:border-primary data-[state=checked]:bg-primary" />
<Label htmlFor={`repo-${repo.id}`} className="flex flex-1 justify-between cursor-pointer">
<div className="flex items-center space-x-2">
<GitBranch size={18} className="text-muted-foreground" />
<span>{repo.name}</span>
</div>
<span className="text-muted-foreground text-sm">{repo.updatedAt}</span>
</Label>
</div>
))}
</RadioGroup>
</ScrollArea>
</div>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<Button
onClick={handleNext}
disabled={!selectedRepo}
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
export default RepositoryListView;

View File

@ -1,146 +0,0 @@
/**
* TemplateSelectionView.tsx
*
* This component displays a grid of templates for the user to select from.
* It shows template cards with icons, names, and descriptions.
*
* Implementation:
* 1. Display a grid of template cards
* 2. Show template details including icon, name, and description
* 3. Allow selection of a template
* 4. Add navigation controls
* 5. Add proper spacing and responsive behavior
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, ArrowRight, Code } from 'lucide-react';
import React, { useState } from 'react';
import useNavigationStore, { OnboardingStep } from '../store/navigationStore';
// Mock data for templates
const mockTemplates = [
{
id: '1',
name: 'Progressive Web App (PWA)',
description: 'A responsive web app with offline capabilities',
icon: Code,
category: 'web'
},
{
id: '2',
name: 'Design Upload Pack',
description: 'Simple file upload and processing pipeline',
icon: Code,
category: 'utility'
},
{
id: '3',
name: 'React + Redux + TailwindCSS',
description: 'Modern frontend stack with state management',
icon: Code,
category: 'web'
},
{
id: '4',
name: 'Node.js API Starter',
description: 'Backend API with Express and MongoDB',
icon: Code,
category: 'api'
},
{
id: '5',
name: 'E-commerce Platform',
description: 'Full-stack shop with payment processing',
icon: Code,
category: 'fullstack'
},
{
id: '6',
name: 'Static Blog Starter',
description: 'JAMstack blog with markdown support',
icon: Code,
category: 'content'
},
];
const TemplateSelectionView: React.FC = () => {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const { markStepCompleted, goToNextStep, goToPreviousStep } = useNavigationStore();
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
const handleNext = () => {
if (selectedTemplate) {
markStepCompleted(OnboardingStep.TEMPLATE);
goToNextStep();
}
};
return (
<div className="flex flex-col max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold mb-2">Connect</h1>
<p className="text-lg text-muted-foreground">
Connect and import a GitHub repo or start from a template
</p>
</div>
<div className="flex flex-col items-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-primary/10 mb-4">
<Code size={32} className="text-primary" />
</div>
<h2 className="text-2xl font-semibold mb-4">Start with a template</h2>
</div>
{/* Template Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{mockTemplates.map((template) => {
const Icon = template.icon;
const isSelected = selectedTemplate === template.id;
return (
<Card
key={template.id}
className={`cursor-pointer border-2 transition-colors ${
isSelected ? 'border-primary' : 'border-border'
}`}
onClick={() => handleTemplateSelect(template.id)}
>
<CardContent className="p-4 flex items-start space-x-4">
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10 mt-1">
<Icon size={20} className="text-primary" />
</div>
<div>
<h3 className="font-medium">{template.name}</h3>
<p className="text-sm text-muted-foreground">{template.description}</p>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<Button
onClick={handleNext}
disabled={!selectedTemplate}
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
export default TemplateSelectionView;

View File

@ -1,117 +0,0 @@
# Onboarding Flow Optimization Recommendations
Based on the analysis of the current implementation and comparison with the Figma designs, here are recommendations for optimizing the structure and documentation of the onboarding flow components.
## Implemented Improvements ✅
1. **Consistent File Naming**
- ✅ Renamed `ConnectDeployFirstApp.tsx` to `connect-deploy-first-app.tsx` to maintain kebab-case convention
- ✅ All component files now follow the same naming pattern
2. **Step Organization**
- ✅ Each step directory now has an `index.ts` file that exports all components
- ✅ The connect, configure, and deploy steps follow the same pattern
3. **Common Components**
- ✅ Moved shared UI elements to a `common/` subdirectory
- ✅ These include `background-svg.tsx`, `laconic-icon-lettering.tsx`, etc.
4. **Types Extraction**
- ✅ Created a separate `types.ts` file for shared interfaces and types
- ✅ Moved the `Step` type and related interfaces from `store.ts` to this file
5. **Directory Structure**
- ✅ Reorganized into a more logical structure with `common/`, `sidebar/`, and step-specific directories
- ✅ Created appropriate index.ts files to simplify imports
6. **Documentation Standards**
- ✅ Created project-wide documentation standards in `/standards/COMPONENT_DOCUMENTATION.md`
## Remaining Recommendations 🚧
### Documentation Improvements
1. **Component Documentation**
- [ ] Apply JSDoc pattern to all components following the project standards in `/standards/COMPONENT_DOCUMENTATION.md`
- [ ] Include description, props interface, examples, and component hierarchy
2. **Figma References**
- [ ] Add specific Figma node IDs to each component file
- [ ] Example: `/** @see https://www.figma.com/file/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500 */`
3. **Props Documentation**
- [ ] Document all props with JSDoc comments in each component
- [ ] Include type information, description, and default values
### Implementation Recommendations
1. **State Management**
- [ ] Add form validation to the store
- [ ] Consider adding persistence for the onboarding flow state
2. **Component Splitting**
- [ ] Break down large components into smaller, focused ones
- [ ] For example, separate form elements in the configure step
3. **Progressive Enhancement**
- [ ] Implement loading states for asynchronous operations
- [ ] Add error handling for API requests
- [ ] Add animations for transitions between steps
4. **Accessibility**
- [ ] Ensure all interactive elements have proper ARIA attributes
- [ ] Add keyboard navigation
- [ ] Test with screen readers
## Current Directory Structure
```
onboarding-flow/
├── README.md
├── OPTIMIZATION.md
├── index.ts
├── types.ts
├── store.ts
├── Onboarding.tsx
├── common/
│ ├── index.ts
│ ├── background-svg.tsx
│ ├── laconic-icon-lettering.tsx
│ ├── onboarding-container.tsx
│ ├── step-header.tsx
│ └── step-navigation.tsx
├── sidebar/
│ ├── index.ts
│ └── sidebar-nav.tsx
├── connect-step/
│ ├── index.ts
│ ├── connect-step.tsx
│ ├── connect-button.tsx
│ ├── connect-initial.tsx
│ ├── connect-deploy-first-app.tsx
│ ├── repository-list.tsx
│ └── template-list.tsx
├── configure-step/
│ ├── index.ts
│ └── configure-step.tsx
└── deploy-step/
├── index.ts
└── deploy-step.tsx
```
## Next Steps
1. Complete the documentation of all components using the project-wide standards
2. Implement the missing features identified in the README.md
3. Consider adding more specialized components for the configure and deploy steps
4. Add unit tests and Storybook stories for all components

View File

@ -1,82 +0,0 @@
/**
* @component Onboarding
* @description Main component that orchestrates the onboarding flow
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
import {
OnboardingContainer, StepNavigation
} from '@/components/onboarding-flow/common';
import { ConfigureStep } from '@/components/onboarding-flow/configure-step';
import { ConnectStep } from '@/components/onboarding-flow/connect-step';
import { DeployStep } from '@/components/onboarding-flow/deploy-step';
import { SidebarNav } from '@/components/onboarding-flow/sidebar';
import { useOnboarding } from '@/components/onboarding-flow/store';
import { ScrollArea } from '@/components/ui/scroll-area';
import { FileCog, GitPullRequest, SquareArrowOutDownRight } from 'lucide-react';
/** Icons for each step in the onboarding flow */
const stepIcons = {
connect: <GitPullRequest className="h-6 w-6 stroke-2" />,
configure: <FileCog className="h-6 w-6 stroke-2" />,
deploy: <SquareArrowOutDownRight className="h-6 w-6 stroke-2" />,
};
/** Titles for each step in the onboarding flow */
const stepTitles = {
connect: 'Connect',
configure: 'Configure',
deploy: 'Deploy',
};
/** Descriptions for each step in the onboarding flow */
const stepDescriptions = {
connect: 'Connect and import a GitHub repository to start deploying.',
configure: 'Set up your deployment configuration and environment variables.',
deploy: 'Review your settings and deploy your project.',
};
/**
* Main onboarding page component
* Orchestrates the entire onboarding flow and manages step transitions
*
* Component Hierarchy:
* - OnboardingContainer
* - SidebarNav (step progress)
* - Main content
* - StepHeader (current step info)
* - Step content (ConnectStep | ConfigureStep | DeployStep)
* - StepNavigation (previous/next controls)
*
* @returns {JSX.Element} Complete onboarding interface
*/
export default function Onboarding() {
const { currentStep, nextStep, previousStep } = useOnboarding();
return (
<OnboardingContainer>
<SidebarNav currentStep={currentStep} />
<div className="flex-1 bg-primary-foreground rounded-lg p-8 shadow-[0_1px_2px_0_rgba(0,0,0,0.06),0_1px_3px_0_rgba(0,0,0,0.1)] flex flex-col overflow-hidden">
{/* <StepHeader
icon={stepIcons[currentStep]}
title={stepTitles[currentStep]}
description={stepDescriptions[currentStep]}
/> */}
<div className="py-4 px-1">
<ScrollArea className="flex-1 mt-6 mb-6">
{currentStep === 'connect' && <ConnectStep />}
{currentStep === 'configure' && <ConfigureStep />}
{currentStep === 'deploy' && <DeployStep />}
</ScrollArea>
</div>
<StepNavigation
currentStep={currentStep}
onPrevious={previousStep}
onNext={nextStep}
nextLabel={currentStep === 'deploy' ? 'Deploy' : 'Next'}
/>
</div>
</OnboardingContainer>
);
}

View File

@ -1,278 +0,0 @@
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { useOctokit } from '@/context/OctokitContext';
import React, { useEffect, useState } from 'react';
import Onboarding from './Onboarding';
import { useOnboarding } from './store';
import { OnboardingFormData, Step } from './types';
// Local storage keys
const ONBOARDING_COMPLETED_KEY = 'onboarding_completed';
const ONBOARDING_STATE_KEY = 'onboarding_state';
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
const ONBOARDING_FORCE_CONNECT_KEY = 'onboarding_force_connect';
interface OnboardingDialogProps {
trigger?: React.ReactNode;
defaultOpen?: boolean;
onClose?: () => void;
}
/**
* OnboardingDialog component
*
* A dialog modal that contains the onboarding flow.
* Can be triggered by a custom element or automatically opened.
* Sets the initial step based on GitHub connection status.
* Provides warnings when exiting mid-step and options to continue progress.
*/
const OnboardingDialog: React.FC<OnboardingDialogProps> = ({
trigger,
defaultOpen = false,
onClose,
}) => {
const onboardingStore = useOnboarding();
const { setCurrentStep, currentStep, formData } = onboardingStore;
const { octokit } = useOctokit();
const [showExitWarning, setShowExitWarning] = useState(false);
const [showContinueAlert, setShowContinueAlert] = useState(false);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [forceConnectStep, setForceConnectStep] = useState(false);
// Check for force connect flag when component mounts
useEffect(() => {
const shouldForceConnect =
localStorage.getItem(ONBOARDING_FORCE_CONNECT_KEY) === 'true';
if (shouldForceConnect) {
setForceConnectStep(true);
// Clear the flag so it's only used once
localStorage.removeItem(ONBOARDING_FORCE_CONNECT_KEY);
}
}, []);
// Local implementation of reset function that handles all necessary state
const resetOnboardingState = () => {
// Reset step to connect
setCurrentStep('connect');
// Flag to force starting from the connect step
setForceConnectStep(true);
// Also reset form data to ensure substeps are cleared
const store = onboardingStore as any;
if (typeof store.updateFormData === 'function') {
store.updateFormData({
projectName: '',
repoName: '',
repoDescription: '',
framework: '',
access: 'public',
organizationSlug: '',
});
}
};
// Close and reset onboarding dialog
const closeOnboarding = () => {
// Remove the "in progress" flag from localStorage
localStorage.removeItem(ONBOARDING_PROGRESS_KEY);
// Also remove saved state to prevent issues on next open
localStorage.removeItem(ONBOARDING_STATE_KEY);
// Reset component state
resetOnboardingState();
setShowContinueAlert(false);
// Explicitly set isOpen to false to ensure dialog closes
setIsOpen(false);
};
// Check if there's existing progress
useEffect(() => {
if (isOpen) {
const savedProgress = localStorage.getItem(ONBOARDING_PROGRESS_KEY);
const savedState = localStorage.getItem(ONBOARDING_STATE_KEY);
if (savedProgress === 'true' && savedState && !forceConnectStep) {
// Show continue or start fresh dialog
setShowContinueAlert(true);
} else {
// Set initial step based on GitHub connection status
initializeOnboarding();
}
}
}, [isOpen, forceConnectStep]);
// Set the initial step based on GitHub connection status
const initializeOnboarding = () => {
// Reset previous state
resetOnboardingState();
// If GitHub is connected AND we're not forcing the connect step,
// start at the configure step. Otherwise, start at the connect step
if (octokit && !forceConnectStep) {
setCurrentStep('configure');
} else {
setCurrentStep('connect');
}
// Mark that we have onboarding in progress
localStorage.setItem(ONBOARDING_PROGRESS_KEY, 'true');
// Save the initial state
saveCurrentState();
};
// Start fresh by initializing onboarding and forcing the connect step
const startFresh = () => {
// Set flag to force starting from the connect step
setForceConnectStep(true);
initializeOnboarding();
setShowContinueAlert(false);
};
// Continue from saved state and don't force the connect step
const continueOnboarding = () => {
// Reset the force flag since we're continuing
setForceConnectStep(false);
loadSavedState();
};
// Save current onboarding state
const saveCurrentState = () => {
try {
const state = {
currentStep,
formData,
forceConnectStep, // Save this flag as part of the state
};
localStorage.setItem(ONBOARDING_STATE_KEY, JSON.stringify(state));
} catch (error) {
console.error('Error saving onboarding state:', error);
}
};
// Load saved onboarding state
const loadSavedState = () => {
try {
const savedState = localStorage.getItem(ONBOARDING_STATE_KEY);
if (savedState) {
const state = JSON.parse(savedState);
// Restore the force flag if it exists
if (state.forceConnectStep !== undefined) {
setForceConnectStep(state.forceConnectStep);
}
setCurrentStep(state.currentStep as Step);
// Also restore form data to preserve org/repo selection
const store = onboardingStore as any;
if (state.formData && typeof store.updateFormData === 'function') {
store.updateFormData(state.formData as Partial<OnboardingFormData>);
}
}
} catch (error) {
console.error('Error loading onboarding state:', error);
initializeOnboarding();
}
setShowContinueAlert(false);
};
// Save state on step changes
useEffect(() => {
if (isOpen) {
saveCurrentState();
}
}, [currentStep, formData, forceConnectStep]);
// Mark onboarding as completed when user reaches the deploy step
useEffect(() => {
if (currentStep === 'deploy') {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
}
}, [currentStep]);
// Handle dialog close attempt
const handleOpenChange = (open: boolean) => {
// First update the isOpen state to ensure UI responds immediately
setIsOpen(open);
if (!open) {
// When dialog is closing, properly clean up
closeOnboarding();
if (onClose) {
onClose();
}
}
};
// Define the missing functions to handle dialog closing
const cancelClose = () => {
setShowExitWarning(false);
};
const completeClose = () => {
closeOnboarding();
setShowExitWarning(false);
};
return (
<>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-[95vw] max-h-[95vh] w-[1200px] h-[800px] overflow-hidden p-0">
<div className="h-full overflow-hidden">
<Onboarding />
</div>
</DialogContent>
</Dialog>
{/* Exit Warning Dialog */}
{/* <AlertDialog open={showExitWarning} onOpenChange={setShowExitWarning}>
<AlertDialogContent>
<AlertDialogTitle>Exit Onboarding?</AlertDialogTitle>
<AlertDialogDescription>
You haven't completed the onboarding process. If you exit now, your
progress will be lost, including any organization or repository
selections.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelClose}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={completeClose}>
Exit Anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> */}
{/* Continue Progress Dialog */}
{/* <AlertDialog open={showContinueAlert} onOpenChange={setShowContinueAlert}>
<AlertDialogContent>
<AlertDialogTitle>Continue Onboarding?</AlertDialogTitle>
<AlertDialogDescription>
You're in the middle of setting up your project, including
organization and repository selection. Would you like to continue
where you left off or start fresh?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={startFresh}>
Start Fresh
</AlertDialogCancel>
<AlertDialogAction onClick={continueOnboarding}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> */}
</>
);
};
/**
* Helper function to check if the user has completed onboarding
* @returns {boolean} Whether onboarding has been completed
*/
export const hasCompletedOnboarding = (): boolean => {
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
};
export default OnboardingDialog;

View File

@ -1,109 +0,0 @@
# Laconic Onboarding Flow
This directory contains the components that make up the onboarding flow for the Laconic platform. The onboarding process guides users through connecting their GitHub repository, configuring their deployment settings, and deploying their application.
## 📝 Figma Design Reference
The design for this component is available at:
[Laconic Design - Onboarding Flow](https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev)
## 🏗️ Component Structure
The onboarding flow is structured as a multi-step wizard with three main steps:
1. **Connect** - Allows users to connect to GitHub and select a repository
2. **Configure** - Lets users configure deployment settings and environment variables
3. **Deploy** - Finalizes the setup and deploys the application
### Directory Structure
```
onboarding-flow/
├── README.md # This file
├── OPTIMIZATION.md # Optimization recommendations
├── DOCUMENTATION_TEMPLATE.md # Documentation templates
├── index.ts # Main exports
├── types.ts # Shared types
├── store.ts # Zustand store for state management
├── Onboarding.tsx # Main component that orchestrates the flow
├── common/ # Shared components
│ ├── index.ts # Exports for common components
│ ├── background-svg.tsx # Background visual element
│ ├── laconic-icon-lettering.tsx # Laconic branding component
│ ├── onboarding-container.tsx # Container layout for the onboarding UI
│ ├── step-header.tsx # Header for each step
│ └── step-navigation.tsx # Navigation controls (prev/next buttons)
├── sidebar/ # Sidebar components
│ ├── index.ts # Exports for sidebar components
│ └── sidebar-nav.tsx # Sidebar showing all steps and progress
├── connect-step/ # Components for the Connect step
│ ├── index.ts # Exports for Connect step
│ ├── connect-step.tsx # Main component for the Connect step
│ ├── connect-button.tsx # GitHub connection button
│ ├── connect-initial.tsx # Initial Connect screen
│ ├── connect-deploy-first-app.tsx # First app deployment guidance
│ ├── repository-list.tsx # List of GitHub repositories
│ └── template-list.tsx # List of available templates
├── configure-step/ # Components for the Configure step
│ ├── index.ts # Exports for Configure step
│ └── configure-step.tsx # Main component for the Configure step
└── deploy-step/ # Components for the Deploy step
├── index.ts # Exports for Deploy step
└── deploy-step.tsx # Main component for the Deploy step
```
## 🔄 State Management
The onboarding flow uses Zustand for state management. The store is defined in `store.ts` and exposed through the `useOnboarding` hook.
## ✅ Completed Items
- ✅ Basic component structure following the Figma design
- ✅ Step navigation with progress indicators
- ✅ Sidebar navigation showing all steps
- ✅ Connect step UI with repository and template selection
- ✅ Configure step with deployment type selection
- ✅ Deploy step with deployment status
- ✅ Organized directory structure
- ✅ Consistent file naming conventions
- ✅ Centralized type definitions
- ✅ Main documentation and README
## 🚧 To-Do Items
- [ ] Complete JSDoc documentation for all components
- [ ] Implement actual GitHub API integration for repository fetching
- [ ] Add form validation for configuration inputs
- [ ] Implement actual deployment functionality
- [ ] Add error handling for API requests
- [ ] Add loading states for asynchronous operations
- [ ] Implement responsive design for mobile devices
- [ ] Add unit tests for all components
- [ ] Add Storybook stories for component documentation
- [ ] Implement animations for step transitions
## 📋 Usage Guidelines
To use the onboarding flow in your application:
```tsx
import { Onboarding } from '@/components/onboarding-flow';
export default function OnboardingPage() {
return <Onboarding />;
}
```
The onboarding flow is self-contained and manages its own state through the Zustand store.
## 🧩 Component Hierarchy
```
Onboarding
├── OnboardingContainer
│ ├── SidebarNav
│ └── Main Content
│ ├── StepHeader
│ ├── Step Content (ConnectStep | ConfigureStep | DeployStep)
│ └── StepNavigation
```

View File

@ -1,13 +0,0 @@
export function BackgroundSVG() {
return (
<svg width="268" height="270" viewBox="0 0 268 270" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.272 155.661C56.6042 121.33 77.8555 73.9178 77.8459 21.5708C77.8586 14.4654 77.4662 7.43923 76.6865 0.5L-27.5 0.509485L-27.4968 200.691C-27.5063 224.957 -18.2546 249.229 0.250641 267.733C18.7573 286.238 43.0469 295.501 67.3161 295.49L267.5 295.5L267.493 191.299C260.569 190.536 253.542 190.141 246.421 190.139C194.088 190.147 146.674 211.395 112.341 245.726C87.3587 270.083 47.3035 270.087 22.6295 245.413C-2.02868 220.754 -2.03989 180.687 22.272 155.661ZM245.89 22.1623C217.117 -6.60845 170.373 -6.61953 141.591 22.1623C112.809 50.9426 112.82 97.687 141.591 126.456C170.379 155.242 217.108 155.238 245.89 126.456C274.671 97.6759 274.677 50.9474 245.89 22.1623Z"
fill="#27272A"
/>
</svg>
)
}

View File

@ -1,13 +0,0 @@
/**
* Common components for the onboarding flow
*
* This module exports shared components used across the onboarding flow.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
export { BackgroundSVG } from './background-svg';
export { LaconicIconLettering } from './laconic-icon-lettering';
export { OnboardingContainer } from './onboarding-container';
export { StepHeader } from './step-header';
export { StepNavigation } from './step-navigation';

View File

@ -1,42 +0,0 @@
export function LaconicIconLettering() {
return (
<svg width="115" height="20" viewBox="0 0 115 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_498_4195)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.37388 10.5194C5.70149 8.19185 7.14225 4.97748 7.1416 1.42853C7.14246 0.94681 7.11586 0.470456 7.063 0L-0.000488281 0.000643078L-0.000273922 13.5723C-0.000917354 15.2174 0.62632 16.863 1.88091 18.1175C3.1356 19.3721 4.78235 20.0001 6.42772 19.9993L19.9995 20L19.999 12.9355C19.5296 12.8838 19.0532 12.857 18.5704 12.8569C15.0224 12.8574 11.8079 14.298 9.48026 16.6255C7.78654 18.2768 5.07093 18.2771 3.39812 16.6043C1.72638 14.9325 1.72562 12.2161 3.37388 10.5194ZM18.5344 1.46863C16.5837 -0.481929 13.4146 -0.48268 11.4633 1.46863C9.512 3.41984 9.51276 6.58895 11.4633 8.53941C13.415 10.491 16.5831 10.4907 18.5344 8.53941C20.4857 6.5882 20.4861 3.42016 18.5344 1.46863Z"
fill="currentColor"
/>
<path d="M31.4741 18.5838H39.2552V16.3302H34.075V1.41351H31.4741V18.5838Z" fill="currentColor" />
<path
d="M49.8108 1.41351H45.4976L40.9893 18.5838H43.6769L44.8039 14.2913H50.3744L51.5014 18.5838H54.3191L49.8108 1.41351ZM45.3458 12.145L47.6 3.2593H47.6866L49.8541 12.145H45.3458Z"
fill="currentColor"
/>
<path
d="M62.9292 8.06885H65.9636C65.9636 3.17534 64.3814 1.07196 60.6968 1.07196C56.817 1.07196 55.1479 3.73341 55.1479 9.97909C55.1479 16.2462 56.817 18.9291 60.6968 18.9291C64.3814 18.9291 65.9636 16.8901 65.9853 12.1468H62.9509C62.9293 15.8599 62.4741 16.7828 60.6968 16.7828C58.6594 16.7828 58.1607 15.4307 58.1824 9.97909C58.1824 4.54896 58.681 3.19678 60.6968 3.21823C62.4741 3.21823 62.9292 4.18413 62.9292 8.06885Z"
fill="currentColor"
/>
<path
d="M73.7781 1.07209C77.7229 1.09364 79.4135 3.77643 79.4135 10.0007C79.4135 16.2249 77.7229 18.9078 73.7781 18.9292C69.8117 18.9507 68.1211 16.2678 68.1211 10.0007C68.1211 3.73354 69.8117 1.05064 73.7781 1.07209ZM71.1555 10.0007C71.1555 15.4308 71.6757 16.783 73.7781 16.783C75.8589 16.783 76.3791 15.4308 76.3791 10.0007C76.3791 4.54909 75.8589 3.19691 73.7781 3.21847C71.6757 3.23992 71.1555 4.59209 71.1555 10.0007Z"
fill="currentColor"
/>
<path
d="M85.0819 18.5624L82.481 18.5838V1.41351H87.0544L91.3243 15.4073H91.3676V1.41351H93.968V18.5838H89.677L85.1254 3.51689H85.0819V18.5624Z"
fill="currentColor"
/>
<path d="M100.468 1.41351H97.8677V18.5838H100.468V1.41351Z" fill="currentColor" />
<path
d="M111.139 8.06885H114.174C114.174 3.17534 112.591 1.07196 108.906 1.07196C105.028 1.07196 103.358 3.73341 103.358 9.97909C103.358 16.2462 105.028 18.9291 108.906 18.9291C112.591 18.9291 114.174 16.8901 114.195 12.1468H111.161C111.139 15.8599 110.684 16.7828 108.906 16.7828C106.869 16.7828 106.371 15.4307 106.393 9.97909C106.393 4.54896 106.891 3.19678 108.906 3.21823C110.684 3.21823 111.139 4.18413 111.139 8.06885Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_498_4195">
<rect width="115" height="20" fill="white" />
</clipPath>
</defs>
</svg>
)
}

View File

@ -1,34 +0,0 @@
import type React from "react"
/**
* Props for the OnboardingContainer component
* @interface OnboardingContainerProps
* @property {React.ReactNode} children - The content to be rendered inside the container
*/
interface OnboardingContainerProps {
children: React.ReactNode
}
/**
* A container component that provides the layout structure for the onboarding flow
*
* @component
* @example
* ```tsx
* <OnboardingContainer>
* <SidebarNav currentStep={currentStep} />
* <MainContent />
* </OnboardingContainer>
* ```
*
* @param {OnboardingContainerProps} props - Component props
* @returns {JSX.Element} A container with consistent sizing and layout
*/
export function OnboardingContainer({ children }: OnboardingContainerProps) {
return (
<div className="min-h-screen w-full bg-background flex items-center justify-center p-8 relative overflow-hidden">
<div className="flex gap-6 w-full max-w-[1200px] min-h-[700px] h-full relative z-10 overflow-hidden">{children}</div>
</div>
)
}

View File

@ -1,46 +0,0 @@
import type React from "react"
/**
* Props for the StepHeader component
* @interface StepHeaderProps
* @property {React.ReactNode} icon - The icon to display next to the header
* @property {string} title - The title of the current step
* @property {string} description - A brief description of the current step
*/
interface StepHeaderProps {
icon: React.ReactNode
title: string
description: string
}
/**
* Displays the header for the current onboarding step
* Used at the top of each step's content area
*
* @component
* @example
* ```tsx
* <StepHeader
* icon={<GitPullRequest />}
* title="Connect"
* description="Connect and import a GitHub repository"
* />
* ```
*
* @param {StepHeaderProps} props - Component props
* @returns {JSX.Element} A header with icon, title, and description
*/
export function StepHeader({ icon, title, description }: StepHeaderProps) {
return (
<div className="flex items-center gap-3.5 mb-8">
<div className="flex items-center justify-center w-11 h-11 bg-foreground rounded-lg p-2.5">
<div className="text-accent">{icon}</div>
</div>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
)
}

View File

@ -1,80 +0,0 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Step } from '../types';
/**
* Props for the StepNavigation component
* @interface StepNavigationProps
* @property {Step} currentStep - The current active step in the onboarding flow
* @property {() => void} [onPrevious] - Callback function for the previous button
* @property {() => void} [onNext] - Callback function for the next button
* @property {string} [nextLabel] - Custom label for the next button
*/
interface StepNavigationProps {
currentStep: Step;
onPrevious?: () => void;
onNext?: () => void;
nextLabel?: string;
}
/** Order of steps in the onboarding flow */
const STEP_ORDER: Step[] = ['connect', 'configure', 'deploy'];
/**
* Navigation component for moving between onboarding steps
* Displays progress indicators and previous/next buttons
*
* @component
* @example
* ```tsx
* <StepNavigation
* currentStep="connect"
* onPrevious={handlePrevious}
* onNext={handleNext}
* nextLabel="Continue"
* />
* ```
*
* @param {StepNavigationProps} props - Component props
* @returns {JSX.Element} Navigation controls with progress indicators
*/
export function StepNavigation({
currentStep,
onPrevious,
onNext,
nextLabel = 'Next',
}: StepNavigationProps) {
const currentStepIndex = STEP_ORDER.indexOf(currentStep);
const totalSteps = STEP_ORDER.length;
return (
<div className="flex items-center justify-between mt-8">
<Button
variant="ghost"
onClick={onPrevious}
disabled={currentStepIndex === 0}
className={cn(
'text-foreground hover:text-foreground/80',
currentStepIndex === 0 &&
'text-muted-foreground hover:text-muted-foreground',
)}
>
Previous
</Button>
<div className="flex gap-2">
{STEP_ORDER.map((step, index) => (
<div
key={step}
className={cn(
'h-1 w-8 rounded-full transition-colors',
index === currentStepIndex ? 'bg-zinc-800' : 'bg-zinc-300',
)}
/>
))}
</div>
<Button onClick={onNext} disabled={currentStepIndex === totalSteps - 1}>
{currentStepIndex === totalSteps - 1 ? 'Deploy' : nextLabel}
</Button>
</div>
);
}

View File

@ -1,69 +0,0 @@
import { useOnboarding } from "@/components/onboarding-flow/store"
import Configure from "@/components/projects/create/Configure"
import { FileCog } from "lucide-react"
import { useState } from "react"
/**
* Second step in the onboarding flow
* Handles deployment configuration and environment setup
*
* Features:
* - Deployment type selection (auction/LRN)
* - Environment variable configuration
* - Account selection
*
* @component
*/
export function ConfigureStep() {
const { formData, setFormData } = useOnboarding()
const [activeTab, setActiveTab] = useState<"create-auction" | "deployer-lrn">("create-auction")
const [environments, setEnvironments] = useState({
production: false,
preview: false,
development: false,
})
// const handleEnvironmentChange = (env: keyof typeof environments) => {
// setEnvironments((prev) => ({
// ...prev,
// [env]: !prev[env],
// }))
// setFormData({
// environmentVars: {
// ...formData.environmentVars,
// [env]: !environments[env],
// },
// })
// }
return (
<div className="w-full">
<div className="max-w-2xl mx-auto space-y-8">
<div className="flex flex-col items-center justify-center w-full max-w-[445px] mx-auto">
<div className="w-full flex flex-col items-center gap-6">
{/* Header section with icon and description */}
<div className="flex flex-col items-center gap-1">
<FileCog className="w-16 h-16 text-foreground" />
<div className="flex flex-col items-center gap-1">
<h2 className="text-2xl font-bold text-foreground">Configure</h2>
<p className="text-base text-muted-foreground text-center">
Set the deployer LRN for a single deployment or by creating a deployer auction for multiple deployments
</p>
</div>
</div>
{/* Content sections will be placed here:
1. Deployment type tabs (auction/LRN)
2. Configuration forms
3. Environment variables
4. Account selection
...content here/ */}
<Configure/>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,9 +0,0 @@
/**
* Configure step components for the onboarding flow
*
* This module exports components related to the Configure step of the onboarding flow.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
export { ConfigureStep } from './configure-step';

View File

@ -1,52 +0,0 @@
import { Button, ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Github } from 'lucide-react';
import * as React from 'react';
interface ConnectButtonProps extends Omit<ButtonProps, 'leftIcon'> {
/**
* Optional override for the icon
*/
icon?: React.ReactNode;
/**
* Optional label for the button
*/
label?: string;
}
/**
* A specialized button for the Connect step based on Figma design
* Used for connecting to services like GitHub
*
* Figma Design Reference:
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=498-3285&m=dev
*/
export function ConnectButton({
className,
icon = <Github />,
label = 'Connect to GitHub',
children = label,
variant = 'outline',
size = 'lg',
...props
}: ConnectButtonProps) {
return (
<Button
className={cn(
'font-medium rounded-md transition-all duration-200',
'bg-white text-foreground',
'focus:ring-2 focus:ring-primary/25 focus:ring-offset-2',
'border border-zinc-200',
'py-2 px-4 h-10',
'flex items-center justify-center gap-2',
className,
)}
variant={variant}
size={size}
leftIcon={icon}
{...props}
>
{children}
</Button>
);
}

View File

@ -1,39 +0,0 @@
/**
* This component is used to connect the first account to the user's GitHub account.
* Should only show if not connected and have no projects
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=498-3262&m=dev
*/
import { Shapes } from 'lucide-react';
import { ConnectButton } from './connect-button';
export default function ConnectDeployFirstApp() {
return (
<div className="flex flex-col items-center justify-center gap-6 w-full max-w-[573px]">
<div className="flex flex-col items-center justify-center gap-6 px-16 w-full">
<div className="flex flex-col items-center gap-6 w-full max-w-[445px]">
<div className="w-16 h-16">
<Shapes className="w-full h-full text-foreground" />
</div>
<div className="flex flex-col items-center gap-1">
<h2 className="text-2xl font-bold text-foreground text-center leading-8">
Deploy your first app
</h2>
<p className="text-base text-muted-foreground text-center leading-6">
Once connected, you can import a repository from your account or
start with one of our templates.
</p>
</div>
<ConnectButton />
</div>
</div>
<div className="flex items-center gap-2.5 w-[276px]">
<div className="flex-1 h-1.5 rounded-full bg-primary" />
<div className="flex-1 h-1.5 rounded-full bg-muted-foreground/30" />
<div className="flex-1 h-1.5 rounded-full bg-muted-foreground/30" />
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
import { Button } from "@/components/ui/button"
import { GitHubLogoIcon } from "@radix-ui/react-icons"
interface ConnectInitialProps {
onConnect: () => void
}
export function ConnectInitial({ onConnect }: ConnectInitialProps) {
return (
<div className="flex flex-col items-center justify-center max-w-md mx-auto text-center">
<div className="mb-6">
// TODO: use lucide icon gitpullrequest in place of svg
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="12" r="4" fill="currentColor" />
<circle cx="12" cy="36" r="4" fill="currentColor" />
<circle cx="36" cy="36" r="4" fill="currentColor" />
<line x1="24" y1="16" x2="14" y2="33" stroke="currentColor" strokeWidth="2" />
<line x1="24" y1="16" x2="34" y2="33" stroke="currentColor" strokeWidth="2" />
<line x1="14" y1="36" x2="34" y2="36" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">Deploy your first app</h2>
<p className="text-muted-foreground mb-6">
Once connected, you can import a repository from your account or start with one of our templates.
</p>
<Button onClick={onConnect} className="gap-2">
<GitHubLogoIcon className="h-4 w-4" />
Connect to GitHub
</Button>
</div>
)
}

View File

@ -1,61 +0,0 @@
'use client';
import { useOnboarding } from '@/components/onboarding-flow/store';
import ConnectAccount from '@/components/projects/create/ConnectAccount';
import { useState } from 'react';
import { ConnectButton } from './connect-button';
type ConnectState = 'initial' | 'repository-select' | 'template-select';
/**
* First step in the onboarding flow
* Handles GitHub connection and repository selection
*
* States:
* - initial: Shows GitHub connect button
* - repository-select: Shows list of repositories
* - template-select: Shows available templates
*
* @component
*/
export function ConnectStep() {
const [connectState, setConnectState] = useState<ConnectState>('initial');
const [projectName, setProjectName] = useState('');
const { setFormData, nextStep } = useOnboarding();
const handleConnect = () => {
setConnectState('repository-select');
};
const handleRepositorySelect = (repo: { name: string }) => {
setFormData({ repoName: repo.name });
nextStep();
};
const handleTemplateSelect = (template: { id: string; name: string }) => {
setFormData({
repoName: projectName,
framework: template.id,
});
nextStep();
};
return (
<div className="max-w-2xl w-full">
{/* <ConnectAccountTabPanel />\ */}
{connectState === 'initial' ? (
<div className="flex flex-col items-center justify-center gap-6 p-8">
<h2 className="text-2xl font-semibold text-center">
Connect to GitHub
</h2>
<p className="text-center text-muted-foreground">
Connect your GitHub account to get started
</p>
<ConnectButton onClick={handleConnect} />
</div>
) : (
<ConnectAccount onAuth={() => {}} />
)}
</div>
);
}

View File

@ -1,14 +0,0 @@
/**
* Connect step components for the onboarding flow
*
* This module exports components related to the Connect step of the onboarding flow.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
export * from './connect-button';
export * from './connect-deploy-first-app';
export * from './connect-initial';
export * from './connect-step';
export * from './repository-list';
export * from './template-list';

View File

@ -1,45 +0,0 @@
import { Button } from '@/components/ui/button';
import { GitHubLogoIcon } from '@radix-ui/react-icons';
interface Repository {
name: string;
updatedAt: string;
}
interface RepositoryListProps {
repositories: Repository[];
onSelect: (repo: Repository) => void;
}
interface RepoCardProps {
repo: Repository;
onClick: () => void;
}
function RepoCard({ repo, onClick }: RepoCardProps) {
return (
<Button
onClick={onClick}
variant="ghost"
className="w-full flex items-center gap-3 p-3 hover:bg-accent hover:text-accent-foreground text-left"
aria-label={`Select repository ${repo.name}`}
>
<GitHubLogoIcon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm">{repo.name}</span>
<span className="text-xs text-muted-foreground">{repo.updatedAt}</span>
</Button>
);
}
export function RepositoryList({
repositories,
onSelect,
}: RepositoryListProps) {
return (
<div className="space-y-2">
{repositories.map((repo) => (
<RepoCard key={repo.name} repo={repo} onClick={() => onSelect(repo)} />
))}
</div>
);
}

View File

@ -1,51 +0,0 @@
import type React from "react"
import { Input } from "@/components/ui/input"
interface Template {
id: string
name: string
description: string
icon: React.ReactNode
}
interface TemplateListProps {
templates: Template[]
onSelect: (template: Template) => void
projectName: string
onProjectNameChange: (name: string) => void
}
export function TemplateList({ templates, onSelect, projectName, onProjectNameChange }: TemplateListProps) {
return (
<div className="space-y-6">
<div className="space-y-2">
<label htmlFor="project-name" className="text-sm font-medium text-foreground">
Project Name
</label>
<Input
id="project-name"
value={projectName}
onChange={(e) => onProjectNameChange(e.target.value)}
placeholder="new-repository-name"
className="bg-background"
/>
</div>
<div className="space-y-2">
{templates.map((template) => (
<button
key={template.id}
onClick={() => onSelect(template)}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-accent hover:text-accent-foreground transition-colors text-left"
>
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center">{template.icon}</div>
<div>
<div className="text-sm font-medium">{template.name}</div>
<div className="text-xs text-muted-foreground">{template.description}</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@ -1,53 +0,0 @@
"use client"
import { useOnboarding } from "@/components/onboarding-flow/store"
import Deploy from "@/components/projects/create/Deploy"
/**
* Final step in the onboarding flow
* Displays deployment summary and triggers deployment
*
* Features:
* - Configuration summary
* - Repository display
* - Deploy action
*
* @component
*/
export function DeployStep() {
const { formData } = useOnboarding()
const handleDeploy = () => {
// TODO: Implement actual deployment logic
console.log("Deploying with configuration:", formData)
}
return (
<div className="w-full">
<div className="max-w-2xl mx-auto space-y-8">
<div className="flex flex-col items-center justify-center w-full max-w-[445px] mx-auto">
<div className="w-full flex flex-col items-center gap-6">
{/* Header section */}
<div className="flex flex-col items-center gap-1">
<div className="flex flex-col items-center gap-1">
<h2 className="text-2xl font-bold text-foreground">Deploy</h2>
<p className="text-base text-muted-foreground text-center">
Your deployment is configured and ready to go!
</p>
</div>
</div>
Content sections will be placed here:
1. Repository info card
2. Configuration summary
3. Deploy button
{/* ...content here */}
<Deploy/>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,9 +0,0 @@
/**
* Deploy step components for the onboarding flow
*
* This module exports components related to the Deploy step of the onboarding flow.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
export { DeployStep } from './deploy-step';

View File

@ -1,30 +0,0 @@
/**
* Onboarding Flow
*
* This module exports all components related to the onboarding flow.
* The onboarding process guides users through connecting their GitHub repository,
* configuring their deployment settings, and deploying their application.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
// Main component
export { default as Onboarding } from './Onboarding';
export { default as OnboardingDialog, hasCompletedOnboarding } from './OnboardingDialog';
// Step components
export { ConfigureStep } from './configure-step';
export { ConnectStep } from './connect-step';
export { DeployStep } from './deploy-step';
// Common components
export * from './common';
// Sidebar components
export * from './sidebar';
// Store and hooks
export { useOnboarding } from './store';
// Types
export * from './types';

View File

@ -1,9 +0,0 @@
/**
* Sidebar components for the onboarding flow
*
* This module exports sidebar navigation components used in the onboarding flow.
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
export { SidebarNav } from './sidebar-nav';

View File

@ -1,138 +0,0 @@
/**
* @component SidebarNav
* @description Sidebar navigation component showing all steps and their status
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
import {
BackgroundSVG,
LaconicIconLettering,
} from '@/components/onboarding-flow/common';
import { type Step } from '@/components/onboarding-flow/types';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { FileCog, GitPullRequest, SquareArrowOutDownRight } from 'lucide-react';
import type React from 'react';
/**
* Props for the StepItem subcomponent
* @interface StepItemProps
* @property {React.ReactNode} icon - Icon representing the step
* @property {string} title - Step title
* @property {string} description - Step description
* @property {boolean} isActive - Whether this step is currently active
*/
interface StepItemProps {
icon: React.ReactNode;
title: string;
description: string;
isActive: boolean;
}
/**
* Individual step item in the sidebar navigation
* @component
* @param {StepItemProps} props - Component props
*/
const StepItem = ({ icon, title, description, isActive }: StepItemProps) => (
<div className="flex items-center gap-3.5">
<div
className={cn(
'flex items-center justify-center w-11 h-11 rounded-lg p-2.5 transition-colors',
isActive ? 'bg-foreground' : 'bg-primary-foreground border',
)}
>
<div
className={cn(
'w-6 h-6 stroke-2 transition-colors',
isActive ? 'text-accent' : 'text-muted-foreground',
)}
>
{icon}
</div>
</div>
<div className="flex flex-col gap-1">
<h3
className={cn(
'text-lg font-semibold leading-7 transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground',
)}
>
{title}
</h3>
<p
className={cn(
'text-sm leading-5 transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground',
)}
>
{description}
</p>
</div>
</div>
);
/**
* Props for the SidebarNav component
* @interface SidebarNavProps
* @property {Step} currentStep - Currently active step in the onboarding flow
*/
interface SidebarNavProps {
currentStep: Step;
}
/**
* Sidebar navigation component showing all steps and their status
*
* @component
* @example
* ```tsx
* <SidebarNav currentStep="connect" />
* ```
*
* Interacts with:
* - useOnboarding store for step state
* - StepItem subcomponent for individual step display
*
* @param {SidebarNavProps} props - Component props
* @returns {JSX.Element} Sidebar with step navigation
*/
export function SidebarNav({ currentStep }: SidebarNavProps) {
return (
<div className="w-[379px] p-6 bg-primary-foreground rounded-lg shadow-[0_1px_2px_0_rgba(0,0,0,0.06),0_1px_3px_0_rgba(0,0,0,0.1)] relative overflow-hidden">
<div className="h-5 mb-6 text-zinc-800 dark:text-white">
<LaconicIconLettering />
</div>
<Separator className="mb-6" />
<div className="space-y-6">
<StepItem
icon={<GitPullRequest />}
title="Connect"
description="Connect and import a GitHub repo"
isActive={currentStep === 'connect'}
/>
<StepItem
icon={<FileCog />}
title="Configure"
description="Define the deployment type"
isActive={currentStep === 'configure'}
/>
<StepItem
icon={<SquareArrowOutDownRight />}
title="Deploy"
description="Review and confirm deployment"
isActive={currentStep === 'deploy'}
/>
</div>
<div className="absolute bottom-0 left-0">
<BackgroundSVG />
</div>
</div>
);
}

View File

@ -1,68 +0,0 @@
/**
* Zustand store for managing onboarding state
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
import { create } from 'zustand';
import { type OnboardingFormData, type Step } from './types';
/**
* State management for the onboarding flow
* @interface OnboardingState
* @property {Step} currentStep - Current active step
* @property {OnboardingFormData} formData - Collected form data
* @property {(step: Step) => void} setCurrentStep - Updates the current step
* @property {(data: Partial<OnboardingFormData>) => void} setFormData - Updates form data
* @property {() => void} nextStep - Moves to the next step
* @property {() => void} previousStep - Moves to the previous step
*/
interface OnboardingState {
currentStep: Step;
formData: OnboardingFormData;
setCurrentStep: (step: Step) => void;
setFormData: (data: Partial<OnboardingFormData>) => void;
nextStep: () => void;
previousStep: () => void;
}
/** Order of steps in the onboarding flow */
const STEP_ORDER: Step[] = ['connect', 'configure', 'deploy'];
/**
* Zustand store for managing onboarding state
* Used across all onboarding components to maintain flow state
*
* @example
* ```tsx
* const { currentStep, formData, nextStep } = useOnboarding()
* ```
*/
export const useOnboarding = create<OnboardingState>((set) => ({
currentStep: 'connect',
formData: {
projectName: '',
repoName: '',
repoDescription: '',
framework: '',
access: 'public',
organizationSlug: '',
},
setCurrentStep: (step) => set({ currentStep: step }),
setFormData: (data) =>
set((state) => ({
formData: { ...state.formData, ...data },
})),
nextStep: () =>
set((state) => {
const currentIndex = STEP_ORDER.indexOf(state.currentStep);
const nextStep = STEP_ORDER[currentIndex + 1];
return nextStep ? { currentStep: nextStep } : state;
}),
previousStep: () =>
set((state) => {
const currentIndex = STEP_ORDER.indexOf(state.currentStep);
const previousStep = STEP_ORDER[currentIndex - 1];
return previousStep ? { currentStep: previousStep } : state;
}),
}));

View File

@ -1,69 +0,0 @@
import { create } from 'zustand';
import { OnboardingFormData, Step } from './types';
// Define the state for the onboarding flow
export interface OnboardingState {
currentStep: Step;
setCurrentStep: (step: Step) => void;
nextStep: () => void;
previousStep: () => void;
formData: OnboardingFormData;
updateFormData: (data: Partial<OnboardingFormData>) => void;
resetOnboarding: () => void;
}
// Create the store with the initial state
export const useOnboarding = create<OnboardingState>((set) => {
// The steps in order
const STEPS: Step[] = ['connect', 'configure', 'deploy'];
// Initial form data
const initialFormData: OnboardingFormData = {
projectName: '',
repoName: '',
repoDescription: '',
framework: '',
access: 'public',
organizationSlug: '',
};
return {
// Current step state (start with the connect step)
currentStep: 'connect',
// Function to set the current step
setCurrentStep: (step) => set({ currentStep: step }),
// Function to move to the next step
nextStep: () => set((state) => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex < STEPS.length - 1) {
return { currentStep: STEPS[currentIndex + 1] };
}
return state;
}),
// Function to move to the previous step
previousStep: () => set((state) => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex > 0) {
return { currentStep: STEPS[currentIndex - 1] };
}
return state;
}),
// Form data state
formData: initialFormData,
// Function to update form data
updateFormData: (data) => set((state) => ({
formData: { ...state.formData, ...data }
})),
// Function to reset the onboarding state
resetOnboarding: () => set({
currentStep: 'connect',
formData: initialFormData
}),
};
});

View File

@ -1,89 +0,0 @@
/**
* Shared type definitions for the onboarding flow
*
* @see https://www.figma.com/design/cfMOy1RJasIu3QyzAMBFxB/Laconic?node-id=571-3500&m=dev
*/
/**
* Available steps in the onboarding flow
*/
export type Step = 'connect' | 'configure' | 'deploy';
/**
* Form data collected during the onboarding process
* @interface OnboardingFormData
* @property {string} projectName - Project name
* @property {string} repoName - Repository name
* @property {string} repoDescription - Repository description
* @property {string} framework - Framework used for the project
* @property {string} access - Access level of the repository
* @property {string} organizationSlug - Organization slug
*/
export interface OnboardingFormData {
projectName: string;
repoName: string;
repoDescription: string;
framework: string;
access: 'public' | 'private';
organizationSlug: string;
}
/**
* GitHub repository information
* @interface Repository
* @property {string} id - Unique identifier for the repository
* @property {string} name - Repository name
* @property {string} fullName - Full repository name including owner
* @property {string} [description] - Repository description
* @property {boolean} [isPrivate] - Whether the repository is private
* @property {string} [url] - Repository URL
*/
export interface Repository {
id: string;
name: string;
fullName: string;
description?: string;
isPrivate?: boolean;
url?: string;
}
/**
* Template information for new projects
* @interface Template
* @property {string} id - Unique identifier for the template
* @property {string} name - Template name
* @property {string} [description] - Template description
* @property {string} [thumbnail] - Template thumbnail URL
*/
export interface Template {
id: string;
name: string;
description?: string;
thumbnail?: string;
}
/**
* Deployment type information
* @interface DeploymentType
* @property {string} id - Unique identifier for the deployment type
* @property {string} name - Deployment type name
* @property {string} [description] - Deployment type description
*/
export interface DeploymentType {
id: string;
name: string;
description?: string;
}
/**
* Environment variable definition
* @interface EnvironmentVariable
* @property {string} key - Environment variable key
* @property {string} value - Environment variable value
* @property {boolean} [isSecret] - Whether the variable is a secret
*/
export interface EnvironmentVariable {
key: string;
value: string;
isSecret?: boolean;
}

Some files were not shown because too many files have changed in this diff Show More