diff --git a/.env.example b/.env.example index 61d2a02067..61c43fc946 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,19 @@ POSTIZ_OAUTH_CLIENT_ID="" POSTIZ_OAUTH_CLIENT_SECRET="" # POSTIZ_OAUTH_SCOPE="openid profile email" # default values +# === Elastic APM (optional) +# Backend / Workers APM configuration +# Set `ELASTIC_APM_SERVICE_NAME` to identify the service +ELASTIC_APM_SERVICE_NAME="postiz-backend" +# APM Server URL (APM Server default port is 8200) +ELASTIC_APM_SERVER_URL="http://localhost:8200" + +# Frontend (RUM) configuration — exposed to the browser +NEXT_PUBLIC_ELASTIC_APM_SERVER_URL="http://localhost:8200" +NEXT_PUBLIC_ELASTIC_APM_SERVICE_NAME="postiz-frontend" +# Public frontend URL used for distributed tracing origins +NEXT_PUBLIC_FRONTEND_URL="http://localhost:4200" + # Short Link Service Settings # DUB_TOKEN="" # Your self-hosted Dub API token # DUB_API_ENDPOINT="https://api.dub.co" # Your self-hosted Dub API endpoint diff --git a/.github/workflows/build-and-push-dockerhub.yml b/.github/workflows/build-and-push-dockerhub.yml new file mode 100644 index 0000000000..fb2df49d05 --- /dev/null +++ b/.github/workflows/build-and-push-dockerhub.yml @@ -0,0 +1,86 @@ +name: Build and Push Docker Hub Images + +on: + workflow_dispatch: + push: + branches: + - main + - staging + - dev + +jobs: + build-push: + name: Build and Push + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set short SHA and tag base + id: set-vars + run: | + SHORT_SHA=$(git rev-parse --short=8 HEAD) + BRANCH=${GITHUB_REF#refs/heads/} + if [ "$BRANCH" = "main" ]; then + TAG_BASE=prod + elif [ "$BRANCH" = "staging" ] || [ "$BRANCH" = "dev" ]; then + TAG_BASE=dev + else + echo "Branch '$BRANCH' is not configured for Docker push. Exiting."; + exit 0 + fi + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "tag_base=$TAG_BASE" >> "$GITHUB_OUTPUT" + + - name: Prepare image tags + id: prepare-tags + run: | + TAG_BASE=${{ steps.set-vars.outputs.tag_base }} + SHORT_SHA=${{ steps.set-vars.outputs.short_sha }} + USER=${{ secrets.DOCKERHUB_USERNAME }} + TAGS="docker.io/${USER}/postiz-app:${TAG_BASE}\ndocker.io/${USER}/postiz-app:${TAG_BASE}-${SHORT_SHA}" + if [ "${TAG_BASE}" = "prod" ]; then + TAGS="${TAGS}\ndocker.io/${USER}/postiz-app:latest" + fi + echo "Preparing tags:\n$TAGS" + echo "tags<> $GITHUB_OUTPUT + echo -e "$TAGS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push multi-arch image + uses: docker/build-push-action@v4 + with: + context: . + file: Dockerfile.dev + platforms: linux/amd64,linux/arm64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.prepare-tags.outputs.tags }} + build-args: | + NEXT_PUBLIC_VERSION=${{ steps.set-vars.outputs.short_sha }} + labels: | + org.opencontainers.image.revision=${{ steps.set-vars.outputs.short_sha }} + org.opencontainers.image.source=${{ github.repository }} + + - name: Verify pushed image (list) + run: | + echo "Pushed tags:" + echo "docker.io/${{ secrets.DOCKERHUB_USERNAME }}/postiz-app:${{ steps.set-vars.outputs.tag_base }}" + echo "docker.io/${{ secrets.DOCKERHUB_USERNAME }}/postiz-app:${{ steps.set-vars.outputs.tag_base }}-${{ steps.set-vars.outputs.short_sha }}" diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index e0fc50f640..c1d57968d4 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -9,6 +9,7 @@ on: jobs: build-containers-common: + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest outputs: containerver: ${{ steps.getcontainerver.outputs.containerver }} @@ -19,6 +20,7 @@ jobs: echo "containerver=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" build-containers: + if: github.repository == 'gitroomhq/postiz-app' needs: build-containers-common strategy: matrix: @@ -53,10 +55,13 @@ jobs: -f Dockerfile.dev \ -t ghcr.io/gitroomhq/postiz-app:${{ env.CONTAINERVER }}-${{ matrix.arch }} \ --build-arg NEXT_PUBLIC_VERSION=${{ env.NEXT_PUBLIC_VERSION }} \ + --pull \ + --no-cache \ --provenance=false --sbom=false \ --output "type=registry,name=ghcr.io/gitroomhq/postiz-app:${{ env.CONTAINERVER }}-${{ matrix.arch }}" . build-container-manifest: + if: github.repository == 'gitroomhq/postiz-app' needs: [build-containers, build-containers-common] runs-on: ubuntu-latest steps: diff --git a/.github/workflows/build-extension.yaml b/.github/workflows/build-extension.yaml index d6ab5ffa2e..dc7a96a3a7 100644 --- a/.github/workflows/build-extension.yaml +++ b/.github/workflows/build-extension.yaml @@ -5,6 +5,7 @@ on: jobs: submit: + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 79d648574a..426d155ea6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,6 +14,9 @@ jobs: analyze: name: Analyze (${{ matrix.language }}) + # Upstream-only: forks often don't want/need security event uploads + if: github.repository == 'gitroomhq/postiz-app' + runs-on: 'ubuntu-latest' permissions: security-events: write diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint similarity index 92% rename from .github/workflows/eslint.yml rename to .github/workflows/eslint index 356bb21541..fd81701160 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint @@ -21,6 +21,8 @@ on: jobs: eslint: name: Run eslint scanning + # Upstream-only: forks often don't want/need SARIF security uploads + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/issue-label-triggers.yml b/.github/workflows/issue-label-triggers.yml index 0f0f49ee7b..695ad67340 100644 --- a/.github/workflows/issue-label-triggers.yml +++ b/.github/workflows/issue-label-triggers.yml @@ -7,7 +7,7 @@ on: jobs: closed-public-website: - if: github.event.label.name == 'trigger-public-website' + if: github.repository == 'gitroomhq/postiz-app' && github.event.label.name == 'trigger-public-website' runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/pr-docker-build.yml b/.github/workflows/pr-docker-build.yml index 17eb5c049d..d4c904403d 100644 --- a/.github/workflows/pr-docker-build.yml +++ b/.github/workflows/pr-docker-build.yml @@ -1,13 +1,14 @@ -name: Build and Publish PR Docker Image +name: Build and Publish PR Docker Image (manual) on: - pull_request_target: - types: [opened, synchronize] + workflow_dispatch: -permissions: write-all +# Automatic PR image pushes are disabled in this fork (PR images not required). +# Trigger this workflow manually if you ever need to build/push a PR image. jobs: build-and-publish: + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest environment: @@ -18,7 +19,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.sha }} - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -29,7 +30,7 @@ jobs: - name: Set image tag id: vars - run: echo "IMAGE_TAG=ghcr.io/gitroomhq/postiz-app-pr:${{ github.event.pull_request.number }}" >> $GITHUB_ENV + run: echo "IMAGE_TAG=ghcr.io/gitroomhq/postiz-app-pr:${{ github.run_id }}" >> $GITHUB_ENV - name: Build Docker image from Dockerfile.dev run: docker build -f Dockerfile.dev -t $IMAGE_TAG . diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml index f9b4e4e43a..16f14ae595 100644 --- a/.github/workflows/publish-extension.yml +++ b/.github/workflows/publish-extension.yml @@ -5,6 +5,7 @@ on: jobs: submit: + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 866927afb5..635e36402e 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Link: https://opencollective.com/postiz ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=gitroomhq/postiz-app&type=Date)](https://www.star-history.com/#gitroomhq/postiz-app&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=gitroomhq/postiz-app&type=date&legend=top-left)](https://www.star-history.com/#gitroomhq/postiz-app&type=date&legend=top-left) ## License diff --git a/apps/backend/src/api/routes/agencies.controller.ts b/apps/backend/src/api/routes/agencies.controller.ts index 5eb98c60cc..e2849f96c1 100644 --- a/apps/backend/src/api/routes/agencies.controller.ts +++ b/apps/backend/src/api/routes/agencies.controller.ts @@ -10,7 +10,7 @@ import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create. export class AgenciesController { constructor(private _agenciesService: AgenciesService) {} @Get('/') - async getAgencyByUsers(@GetUserFromRequest() user: User) { + async getAgencyByUser(@GetUserFromRequest() user: User) { return (await this._agenciesService.getAgencyByUser(user)) || {}; } diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index 338bf962cb..abe0e38a77 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -268,4 +268,131 @@ export class AuthController { login: true, }); } + + @Get('/sso/:provider') + async ssoCallbackGet( + @Param('provider') provider: string, + @Query('code') code: string, + @Query('redirect') redirect: string, + @Res({ passthrough: false }) response: Response, + @RealIP() ip: string, + @UserAgent() userAgent: string + ) { + return this.handleSso({ + provider, + token: code, + response, + ip, + userAgent, + redirect: redirect || process.env.FRONTEND_URL || '/', + }); + } + + @Post('/sso/:provider') + async ssoCallbackPost( + @Param('provider') provider: string, + @Body('code') code: string, + @Body('assertion') assertion: string, + @Query('redirect') redirect: string, + @Res({ passthrough: false }) response: Response, + @RealIP() ip: string, + @UserAgent() userAgent: string + ) { + return this.handleSso({ + provider, + token: code || assertion, + response, + ip, + userAgent, + redirect: redirect || process.env.FRONTEND_URL || '/', + }); + } + + private async handleSso({ + provider, + token, + response, + ip, + userAgent, + redirect, + }: { + provider: string; + token: string; + response: Response; + ip: string; + userAgent: string; + redirect?: string; + }) { + try { + const providerValue = Provider[provider?.toUpperCase() as keyof typeof Provider]; + if (!providerValue) { + return response.status(400).send('Unsupported provider'); + } + + if (!token) { + return response.status(400).send('Missing code'); + } + + const { jwt, addedOrg } = await this._authService.sso( + providerValue as unknown as string, + token, + ip, + userAgent + ); + + response.cookie('auth', jwt, { + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + ...(!process.env.NOT_SECURED + ? { + secure: true, + httpOnly: true, + sameSite: 'none', + } + : {}), + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + + if (process.env.NOT_SECURED) { + response.header('auth', jwt); + } + + if (typeof addedOrg !== 'boolean' && addedOrg?.organizationId) { + response.cookie('showorg', addedOrg.organizationId, { + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + ...(!process.env.NOT_SECURED + ? { + secure: true, + httpOnly: true, + sameSite: 'none', + } + : {}), + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + + if (process.env.NOT_SECURED) { + response.header('showorg', addedOrg.organizationId); + } + } + + if (redirect) { + const allowedDomain = new URL(process.env.FRONTEND_URL!).hostname; + const redirectUrl = new URL(redirect, process.env.FRONTEND_URL!); + if (redirectUrl.hostname !== allowedDomain) { + return response.status(400).send('Invalid redirect domain'); + } + return response.redirect(302, redirectUrl.toString()); + } + + response.header('reload', 'true'); + return response.status(200).json({ login: true }); + } catch (e: any) { + if (redirect) { + const url = new URL(redirect, process.env.FRONTEND_URL || undefined); + url.searchParams.set('sso', 'failed'); + return response.redirect(302, url.toString()); + } + + return response.status(400).send(e.message || 'SSO failed'); + } + } } diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 2ba8eb1f24..5856898cc3 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -62,6 +62,23 @@ export class BillingController { }; } + @Post('/embedded') + embedded( + @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User, + @Body() body: BillingSubscribeDto, + @Req() req: Request + ) { + const uniqueId = req?.cookies?.track; + return this._stripeService.embedded( + uniqueId, + org.id, + user.id, + body, + org.allowTrial + ); + } + @Post('/subscribe') subscribe( @GetOrgFromRequest() org: Organization, diff --git a/apps/backend/src/apm.init.ts b/apps/backend/src/apm.init.ts new file mode 100644 index 0000000000..0f3fba0e23 --- /dev/null +++ b/apps/backend/src/apm.init.ts @@ -0,0 +1,19 @@ +/* Elastic APM initializer + This file should be imported as early as possible (see main.ts). + It uses runtime `require` so it can run before TypeScript transpilation concerns. +*/ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* istanbul ignore file */ +const apm = require('elastic-apm-node').start({ + serviceName: process.env.ELASTIC_APM_SERVICE_NAME || 'postiz-backend', + serverUrl: process.env.ELASTIC_APM_SERVER_URL, + environment: process.env.NODE_ENV || 'production', + captureExceptions: true, + captureSpanStackTraces: true, + captureBody: 'errors', + centralConfig: true, + metricsInterval: '30s', + logLevel: process.env.ELASTIC_APM_LOG_LEVEL || 'debug', +}); + +module.exports = apm; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index d1ee6fb7ba..730ac308a9 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,3 +1,4 @@ +import './apm.init'; import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; initializeSentry('backend', true); diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 59c4d11af4..81da83976a 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -245,6 +245,53 @@ export class AuthService { return { token }; } + /** + * Server-to-server SSO / assertion handler that verifies the incoming assertion + * with the configured provider, performs JIT provisioning if needed, and + * returns a local JWT. + */ + async sso(provider: string, assertion: string, ip: string, userAgent: string) { + const providerInstance = ProvidersFactory.loadProvider(provider as Provider); + const token = await providerInstance.getToken(assertion); + const providerUser = await providerInstance.getUser(token); + + if (!providerUser) { + throw new Error('Invalid provider token'); + } + + const user = await this._userService.getUserByProvider( + providerUser.id, + provider as Provider + ); + + if (user) { + return { addedOrg: false, jwt: await this.jwt(user) }; + } + + if (!(await this.canRegister(provider))) { + throw new Error('Registration is disabled'); + } + + const create = await this._organizationService.createOrgAndUser( + { + company: '', + email: providerUser.email, + password: '', + provider: provider as unknown as Provider, + providerId: providerUser.id, + }, + ip, + userAgent + ); + + await NewsletterService.register(providerUser.email); + + const createdUser = create.users?.[0]?.user; + const addedOrg = { organizationId: create.id }; + + return { addedOrg, jwt: await this.jwt(createdUser as any) }; + } + private async jwt(user: User) { return AuthChecker.signJWT(user); } diff --git a/apps/backend/src/services/auth/providers/fusionauth.provider.ts b/apps/backend/src/services/auth/providers/fusionauth.provider.ts new file mode 100644 index 0000000000..b4eea5c15b --- /dev/null +++ b/apps/backend/src/services/auth/providers/fusionauth.provider.ts @@ -0,0 +1,64 @@ +import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; + +export class FusionAuthProvider implements ProvidersInterface { + private issuer = process.env.FUSION_AUTH_BASE_URL!; + private clientId = process.env.FUSION_AUTH_CLIENT_ID!; + private clientSecret = process.env.FUSION_AUTH_CLIENT_SECRET; + private redirectUri = + process.env.FUSION_AUTH_REDIRECT_URI || `${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/sso/fusionauth`; + + generateLink() { + const params = new URLSearchParams({ + client_id: this.clientId, + response_type: 'code', + scope: 'openid profile email', + redirect_uri: this.redirectUri, + }); + + return `${this.issuer}/oauth2/authorize?${params.toString()}`; + } + + async getToken(codeOrToken: string): Promise { + if (!this.clientSecret) { + throw new Error('FusionAuth client secret is not configured'); + } + + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, + }; + + const res = await fetch(`${this.issuer}/oauth2/token`, { + method: 'POST', + headers, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + redirect_uri: this.redirectUri, + code: codeOrToken, + }).toString(), + }); + + if (!res.ok) { + throw new Error(`Token exchange failed: ${await res.text()}`); + } + const json = await res.json(); + const token = json.access_token || json.id_token; + if (!token) { + throw new Error('Token not found in FusionAuth response'); + } + return token; + } + + async getUser(tokenOrIdToken: string): Promise<{ email: string; id: string }> { + const res = await fetch(`${this.issuer}/oauth2/userinfo`, { + headers: { Authorization: `Bearer ${tokenOrIdToken}`, Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error('Failed to fetch userinfo'); + } + const data = await res.json(); + return { id: String(data.sub), email: data.email }; + } +} diff --git a/apps/backend/src/services/auth/providers/providers.factory.ts b/apps/backend/src/services/auth/providers/providers.factory.ts index 2815bc3e29..3b978bd853 100644 --- a/apps/backend/src/services/auth/providers/providers.factory.ts +++ b/apps/backend/src/services/auth/providers/providers.factory.ts @@ -5,6 +5,7 @@ import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google. import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider'; import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider'; import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider'; +import { FusionAuthProvider } from '@gitroom/backend/services/auth/providers/fusionauth.provider'; export class ProvidersFactory { static loadProvider(provider: Provider): ProvidersInterface { @@ -17,6 +18,8 @@ export class ProvidersFactory { return new FarcasterProvider(); case Provider.WALLET: return new WalletProvider(); + case Provider.FUSIONAUTH: + return new FusionAuthProvider(); case Provider.GENERIC: return new OauthProvider(); } diff --git a/apps/cron/src/main.ts b/apps/cron/src/main.ts index 3d3ece5b41..cc2684f96e 100644 --- a/apps/cron/src/main.ts +++ b/apps/cron/src/main.ts @@ -4,9 +4,9 @@ initializeSentry('cron'); import { NestFactory } from '@nestjs/core'; import { CronModule } from './cron.module'; -async function start() { +async function bootstrap() { // some comment again await NestFactory.createApplicationContext(CronModule); } -start(); +bootstrap(); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 85f5f40896..da47767373 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -12,5 +12,8 @@ }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "dependencies": { + "@elastic/apm-rum": "^5.17.0" + } } diff --git a/apps/frontend/public/auth/avatars/ali.jpg b/apps/frontend/public/auth/avatars/ali.jpg new file mode 100644 index 0000000000..a3102f60b7 Binary files /dev/null and b/apps/frontend/public/auth/avatars/ali.jpg differ diff --git a/apps/frontend/public/auth/avatars/andy.jpeg b/apps/frontend/public/auth/avatars/andy.jpeg new file mode 100644 index 0000000000..b07925881a Binary files /dev/null and b/apps/frontend/public/auth/avatars/andy.jpeg differ diff --git a/apps/frontend/public/auth/avatars/anica.jpg b/apps/frontend/public/auth/avatars/anica.jpg new file mode 100644 index 0000000000..2762359a1f Binary files /dev/null and b/apps/frontend/public/auth/avatars/anica.jpg differ diff --git a/apps/frontend/public/auth/avatars/bart.jpg b/apps/frontend/public/auth/avatars/bart.jpg new file mode 100644 index 0000000000..68fa588eb5 Binary files /dev/null and b/apps/frontend/public/auth/avatars/bart.jpg differ diff --git a/apps/frontend/public/auth/avatars/david.jpg b/apps/frontend/public/auth/avatars/david.jpg new file mode 100644 index 0000000000..3fcf60292b Binary files /dev/null and b/apps/frontend/public/auth/avatars/david.jpg differ diff --git a/apps/frontend/public/auth/avatars/dilini.jpeg b/apps/frontend/public/auth/avatars/dilini.jpeg new file mode 100644 index 0000000000..0cbe820a78 Binary files /dev/null and b/apps/frontend/public/auth/avatars/dilini.jpeg differ diff --git a/apps/frontend/public/auth/avatars/george.jpg b/apps/frontend/public/auth/avatars/george.jpg new file mode 100644 index 0000000000..ca11fcba2c Binary files /dev/null and b/apps/frontend/public/auth/avatars/george.jpg differ diff --git a/apps/frontend/public/auth/avatars/henry.jpg b/apps/frontend/public/auth/avatars/henry.jpg new file mode 100644 index 0000000000..72df9f6b59 Binary files /dev/null and b/apps/frontend/public/auth/avatars/henry.jpg differ diff --git a/apps/frontend/public/auth/avatars/iorn.jpg b/apps/frontend/public/auth/avatars/iorn.jpg new file mode 100644 index 0000000000..af7edbc00e Binary files /dev/null and b/apps/frontend/public/auth/avatars/iorn.jpg differ diff --git a/apps/frontend/public/auth/avatars/johna.jpg b/apps/frontend/public/auth/avatars/johna.jpg new file mode 100644 index 0000000000..f36fac83e2 Binary files /dev/null and b/apps/frontend/public/auth/avatars/johna.jpg differ diff --git a/apps/frontend/public/auth/avatars/josh.jpg b/apps/frontend/public/auth/avatars/josh.jpg new file mode 100644 index 0000000000..f00128da58 Binary files /dev/null and b/apps/frontend/public/auth/avatars/josh.jpg differ diff --git a/apps/frontend/public/auth/avatars/kiley.jpeg b/apps/frontend/public/auth/avatars/kiley.jpeg new file mode 100644 index 0000000000..9a25cd4763 Binary files /dev/null and b/apps/frontend/public/auth/avatars/kiley.jpeg differ diff --git a/apps/frontend/public/auth/avatars/maria.jpg b/apps/frontend/public/auth/avatars/maria.jpg new file mode 100644 index 0000000000..0755a50db4 Binary files /dev/null and b/apps/frontend/public/auth/avatars/maria.jpg differ diff --git a/apps/frontend/public/auth/avatars/michael.jpeg b/apps/frontend/public/auth/avatars/michael.jpeg new file mode 100644 index 0000000000..9bd2e64189 Binary files /dev/null and b/apps/frontend/public/auth/avatars/michael.jpeg differ diff --git a/apps/frontend/public/auth/avatars/serge.jpeg b/apps/frontend/public/auth/avatars/serge.jpeg new file mode 100644 index 0000000000..c7879babdd Binary files /dev/null and b/apps/frontend/public/auth/avatars/serge.jpeg differ diff --git a/apps/frontend/public/auth/avatars/vince.jpeg b/apps/frontend/public/auth/avatars/vince.jpeg new file mode 100644 index 0000000000..3357759c72 Binary files /dev/null and b/apps/frontend/public/auth/avatars/vince.jpeg differ diff --git a/apps/frontend/public/auth/avatars/vincent.jpg b/apps/frontend/public/auth/avatars/vincent.jpg new file mode 100644 index 0000000000..6611eab3fb Binary files /dev/null and b/apps/frontend/public/auth/avatars/vincent.jpg differ diff --git a/apps/frontend/public/logo-text.svg b/apps/frontend/public/logo-text.svg new file mode 100644 index 0000000000..835967984a --- /dev/null +++ b/apps/frontend/public/logo-text.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/frontend/public/no-video-youtube.png b/apps/frontend/public/no-video-youtube.png new file mode 100644 index 0000000000..5aa7597dd4 Binary files /dev/null and b/apps/frontend/public/no-video-youtube.png differ diff --git a/apps/frontend/public/stripe.svg b/apps/frontend/public/stripe.svg new file mode 100644 index 0000000000..25d948f16b --- /dev/null +++ b/apps/frontend/public/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/apm.rum.ts b/apps/frontend/src/apm.rum.ts new file mode 100644 index 0000000000..3d171320e6 --- /dev/null +++ b/apps/frontend/src/apm.rum.ts @@ -0,0 +1,24 @@ +/* Elastic APM RUM initializer for the Next.js frontend. + This module is dynamically imported on client-side when + `NEXT_PUBLIC_ELASTIC_APM_SERVER_URL` is present. +*/ +/* istanbul ignore file */ +import { init as initApm } from '@elastic/apm-rum'; + +if (typeof window !== 'undefined') { + try { + initApm({ + serviceName: process.env.NEXT_PUBLIC_ELASTIC_APM_SERVICE_NAME || 'postiz-frontend', + serverUrl: process.env.ELASTIC_APM_SERVER_URL, + environment: process.env.NEXT_PUBLIC_NODE_ENV || process.env.NODE_ENV || 'production', + pageLoadTransactionName: 'route-change', + breakdownMetrics: true, + distributedTracingOrigins: [process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000', 'http://localhost:4200'], + }); + // eslint-disable-next-line no-console + console.info('Elastic APM RUM initialized'); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Failed to initialize Elastic APM RUM', e); + } +} diff --git a/apps/frontend/src/app/(app)/auth/layout.tsx b/apps/frontend/src/app/(app)/auth/layout.tsx index a107cfd4f5..729e7aa60b 100644 --- a/apps/frontend/src/app/(app)/auth/layout.tsx +++ b/apps/frontend/src/app/(app)/auth/layout.tsx @@ -3,9 +3,9 @@ import { getT } from '@gitroom/react/translation/get.translation.service.backend export const dynamic = 'force-dynamic'; import { ReactNode } from 'react'; import Image from 'next/image'; -import clsx from 'clsx'; import loadDynamic from 'next/dynamic'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +import { TestimonialComponent } from '@gitroom/frontend/components/auth/testimonial.component'; +import { LogoTextComponent } from '@gitroom/frontend/components/ui/logo-text.component'; const ReturnUrlComponent = loadDynamic(() => import('./return.url.component')); export default async function AuthLayout({ children, @@ -15,70 +15,24 @@ export default async function AuthLayout({ const t = await getT(); return ( -
+
+ {/**/} -
-
-
-
-
- Logo -
- {isGeneralServerSide() ? ( - - - - - - - ) : ( -
{t('gitroom', 'Gitroom')}
- )} -
-
-
-
- {children} -
-
-
-
-
-
-
-
-
-
-
+
+
+ +
{children}
+
+
+ Over 18,000+{' '} + Entrepreneurs use +
+ Postiz To Grow Their Social Presence +
+ +
); } diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index bea3e05e70..4a60fc05dd 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -13,7 +13,7 @@ import { VariableContextComponent } from '@gitroom/react/helpers/variable.contex import { Fragment } from 'react'; import { PHProvider } from '@gitroom/react/helpers/posthog'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; -import { ToltScript } from '@gitroom/frontend/components/layout/tolt.script'; +import { DubAnalytics } from '@gitroom/frontend/components/layout/dubAnalytics'; import { FacebookComponent } from '@gitroom/frontend/components/layout/facebook.component'; import { headers } from 'next/headers'; import { headerName } from '@gitroom/react/translation/i18n.config'; @@ -52,6 +52,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { environment={process.env.NODE_ENV!} backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!} plontoKey={process.env.NEXT_PUBLIC_POLOTNO!} + stripeClient={process.env.STRIPE_PUBLISHABLE_KEY!} billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY} discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!} frontEndUrl={process.env.FRONTEND_URL!} @@ -60,7 +61,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!} oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!} uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!} - tolt={process.env.NEXT_PUBLIC_TOLT!} + dub={!!process.env.STRIPE_PUBLISHABLE_KEY} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} neynarClientId={process.env.NEYNAR_CLIENT_ID!} @@ -81,7 +82,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { {/**/} - + - + - +
+
+ +
); } diff --git a/apps/frontend/src/app/colors.scss b/apps/frontend/src/app/colors.scss index c0082f7009..e719580878 100644 --- a/apps/frontend/src/app/colors.scss +++ b/apps/frontend/src/app/colors.scss @@ -1,7 +1,11 @@ :root { .dark { + --new-back-drop: #000; + --new-settings: #242323; + --new-border: #252525; --new-bgColor: #0e0e0e; --new-bgColorInner: #1a1919; + --new-sep: #454444; --new-bgLineColor: #212121; --new-textItemFocused: #1a1919; --new-textItemBlur: #999999; @@ -24,8 +28,26 @@ --new-menu-hover: #fff; --menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.5); --popup-color: rgba(65, 64, 66, 0.3); + --border-preview: transparent; + --preview-box-shadow: none; + --linkedin-border: #2e3438; + --linkedin-bg: #1b1f23; + --linkedin-text: #c6c7c8; + --facebook-bg: #242526; + --facebook-bg-comment: #333334; + --instagram-bg: #0b1014; + --tiktok-item-bg: #2a2a2a; + --tiktok-item-icon-bg: #FFF; + --youtube-bg: #0F0F0F; + --youtube-button: #F1F1F1; + --youtube-action-color: #272727; + --youtube-svg-border: #A0A0A0; } .light { + --new-back-drop: #2d1b57; + --new-settings: #eceef1; + --new-sep: #d5d9dd; + --new-border: #eaecee; --new-bgColor: #f0f2f4; --new-bgColorInner: #ffffff; --new-bgLineColor: #e7e9eb; @@ -54,6 +76,23 @@ -3px 13px 14px 0 rgba(55, 52, 75, 0.09), -1px 3px 8px 0 rgba(55, 52, 75, 0.1); --popup-color: rgba(55, 37, 97, 0.2); + --border-preview: #f1f0f3; + --preview-box-shadow: 0 386px 108px 0 rgba(38, 32, 64, 0), + 0 247px 99px 0 rgba(38, 32, 64, 0.01), + 0 139px 83px 0 rgba(38, 32, 64, 0.03), + 0 62px 62px 0 rgba(38, 32, 64, 0.04), 0 15px 34px 0 rgba(38, 32, 64, 0.05); + --linkedin-border: #e9e5df; + --linkedin-bg: #fff; + --linkedin-text: #707070; + --facebook-bg: #fff; + --facebook-bg-comment: #f6f6f6; + --instagram-bg: #fff; + --tiktok-item-bg: #EEF1F0; + --tiktok-item-icon-bg: #454645; + --youtube-bg: #FFF; + --youtube-button: #000; + --youtube-action-color: #F1F1F1; + --youtube-svg-border: #1A1A1A; } } diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 75993ff0df..5a524d1fb2 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -679,6 +679,9 @@ html[dir='rtl'] [dir='ltr'] { .blur-xs { filter: blur(4px); } +.blur-s { + filter: blur(5px); +} .agent { .copilotKitInputContainer { @@ -703,4 +706,78 @@ html[dir='rtl'] [dir='ltr'] { .copilotKitMessage a { color: var(--new-btn-text) !important; +} + +@keyframes marquee-up { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-50%); + } +} + +@keyframes marquee-down { + 0% { + transform: translateY(-50%); + } + 100% { + transform: translateY(0%); + } +} + +.blackGradBottomBg { + background: linear-gradient(180deg, #0e0e0e 0%, rgba(14, 14, 14, 0) 100%); + rotate: 180deg; +} + +.blackGradTopBg { + background: linear-gradient(180deg, #0e0e0e 0%, rgba(14, 14, 14, 0) 100%); +} + +.billing-form input { + background: transparent !important; +} + +.menu-shadow { + border-radius: 12px; + box-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.5); +} + +.post-now { + box-shadow: -33px 57px 18px 0 rgba(0, 0, 0, 0.01), -21px 36px 17px 0 rgba(0, 0, 0, 0.06), -12px 20px 14px 0 rgba(0, 0, 0, 0.20), -5px 9px 11px 0 rgba(0, 0, 0, 0.34), -1px 2px 6px 0 rgba(0, 0, 0, 0.39); +} + +.uppyChange .uppy-Dashboard-inner { + width: 100% !important; +} + +.btnSub:disabled .arrow-change { + display: none !important; +} +.btnSub:disabled + button { + display: none !important; +} + +.tiptap p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.w8-max { + width: calc(100% / 6); + max-width: calc(100% / 6); +} + +.withp3 { + width: calc(100% + 9px); + height: calc(100% + 6px); +} + +.forceChange .changeColor { + background: var(--new-btn-primary) !important; + color: #fff !important; } \ No newline at end of file diff --git a/apps/frontend/src/components/agents/agent.chat.tsx b/apps/frontend/src/components/agents/agent.chat.tsx index 2f8aa0a6b0..e2bc282f34 100644 --- a/apps/frontend/src/components/agents/agent.chat.tsx +++ b/apps/frontend/src/components/agents/agent.chat.tsx @@ -32,11 +32,13 @@ import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.m import dayjs from 'dayjs'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; export const AgentChat: FC = () => { const { backendUrl } = useVariables(); const params = useParams<{ id: string }>(); const { properties } = useContext(PropertiesContext); + const t = useT(); return ( { > Public API -`, +`), }} UserMessage={Message} Input={NewInput} diff --git a/apps/frontend/src/components/agents/agent.tsx b/apps/frontend/src/components/agents/agent.tsx index 54da0f0840..e4ca8b1f90 100644 --- a/apps/frontend/src/components/agents/agent.tsx +++ b/apps/frontend/src/components/agents/agent.tsx @@ -21,6 +21,7 @@ import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.co import { Integration } from '@prisma/client'; import Link from 'next/link'; import { useParams, usePathname, useRouter } from 'next/navigation'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; export const MediaPortal: FC<{ media: { path: string; id: string }[]; @@ -39,13 +40,14 @@ export const MediaPortal: FC<{ }) => void; }> = ({ media, setMedia, value }) => { const waitForClass = useWaitForClass('copilotKitMessages'); + const t = useT(); if (!waitForClass) return null; return (
void }> = ({ onChange, }) => { const fetch = useFetch(); + const t = useT(); const [selected, setSelected] = useState([]); const load = useCallback(async () => { @@ -111,7 +114,7 @@ export const AgentList: FC<{ onChange: (arr: any[]) => void }> = ({

- Select Channels + {t('select_channels', 'Select Channels')}

setCollapseMenu(collapseMenu === '1' ? '0' : '1')} @@ -210,6 +213,7 @@ const Threads: FC = () => { const fetch = useFetch(); const router = useRouter(); const pathname = usePathname(); + const t = useT(); const threads = useCallback(async () => { return (await fetch('/copilot/list')).json(); }, []); @@ -247,7 +251,7 @@ const Threads: FC = () => { />
- Start a new chat + {t('start_a_new_chat', 'Start a new chat')}
diff --git a/apps/frontend/src/components/analytics/stars.and.forks.tsx b/apps/frontend/src/components/analytics/stars.and.forks.tsx index 97950bc0a8..3f7f067ed5 100644 --- a/apps/frontend/src/components/analytics/stars.and.forks.tsx +++ b/apps/frontend/src/components/analytics/stars.and.forks.tsx @@ -138,8 +138,8 @@ export const StarsAndForks: FC = (props) => {
{p === 0 - ? 'Last Github Trending' - : 'Next Predicted GitHub Trending'} + ? t('last_github_trending', 'Last Github Trending') + : t('next_predicted_github_trending', 'Next Predicted GitHub Trending')}
diff --git a/apps/frontend/src/components/analytics/stars.table.component.tsx b/apps/frontend/src/components/analytics/stars.table.component.tsx index ad805eee22..476d6657e7 100644 --- a/apps/frontend/src/components/analytics/stars.table.component.tsx +++ b/apps/frontend/src/components/analytics/stars.table.component.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC, useCallback, @@ -205,22 +207,22 @@ export const StarsTableComponent = () => { - + - + - + - + - + - + {t('media', 'Media')} diff --git a/apps/frontend/src/components/auth/activate.tsx b/apps/frontend/src/components/auth/activate.tsx index a8191c121d..3ffbd42385 100644 --- a/apps/frontend/src/components/auth/activate.tsx +++ b/apps/frontend/src/components/auth/activate.tsx @@ -5,7 +5,7 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; export function Activate() { const t = useT(); return ( - <> +

{t('activate_your_account', 'Activate your account')} @@ -19,6 +19,6 @@ export function Activate() { 'Please check your email to activate your account.' )}

- +
); } diff --git a/apps/frontend/src/components/auth/forgot-return.tsx b/apps/frontend/src/components/auth/forgot-return.tsx index b6108ea07a..4b79ab25a8 100644 --- a/apps/frontend/src/components/auth/forgot-return.tsx +++ b/apps/frontend/src/components/auth/forgot-return.tsx @@ -42,7 +42,7 @@ export function ForgotReturn({ token }: { token: string }) { if (!reset) { form.setError('password', { type: 'manual', - message: 'Your password reset link has expired. Please try again.', + message: t('password_reset_link_expired', 'Your password reset link has expired. Please try again.'), }); return false; } @@ -61,15 +61,17 @@ export function ForgotReturn({ token }: { token: string }) {
diff --git a/apps/frontend/src/components/auth/forgot.tsx b/apps/frontend/src/components/auth/forgot.tsx index 57300f63cb..070404b0c6 100644 --- a/apps/frontend/src/components/auth/forgot.tsx +++ b/apps/frontend/src/components/auth/forgot.tsx @@ -36,52 +36,58 @@ export function Forgot() { setLoading(false); }; return ( - -
-
-

- {t('forgot_password_1', 'Forgot Password')} -

-
- {!state ? ( - <> -
- -
-
-
- +
+ + +
+

+ {t('forgot_password_1', 'Forgot Password')} +

+
+ {!state ? ( + <> +
+ +
+
+
+ +
+

+ + {t('go_back_to_login', 'Go back to login')} + +

+
+ + ) : ( + <> +
+ {t( + 'we_have_send_you_an_email_with_a_link_to_reset_your_password', + 'We have send you an email with a link to reset your password.' + )}

{t('go_back_to_login', 'Go back to login')}

-
- - ) : ( - <> -
- {t( - 'we_have_send_you_an_email_with_a_link_to_reset_your_password', - 'We have send you an email with a link to reset your password.' - )} -
-

- - {t('go_back_to_login', 'Go back to login')} - -

- - )} - - + + )} + + +
); } diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index 6344b918d1..cb194be0d6 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -55,70 +55,81 @@ export function Login() { }; return ( -
-
-

- {t('sign_in', 'Sign In')} -

-
- {isGeneral && genericOauth ? ( - - ) : !isGeneral ? ( - - ) : ( -
- - {!!neynarClientId && } - {billingEnabled && } + +
+
+

+ {t('sign_in', 'Sign In')} +

- )} -
-
-
-
{t('or', 'OR')}
+
+ {t('continue_with', 'Continue With')}
-
- -
- - -
-
-
- +
+ {isGeneral && genericOauth ? ( + + ) : !isGeneral ? ( + + ) : ( +
+ + {!!neynarClientId && } + {billingEnabled && } +
+ )} +
+
+
+
{t('or', 'or')}
+
+
+
+
+ + +
+
+
+ +
+

+ {t('don_t_have_an_account', "Don't Have An Account?")}  + + {t('sign_up', 'Sign Up')} + +

+

+ + {t('forgot_password', 'Forgot password')} + +

+
+
-

- {t('don_t_have_an_account', "Don't Have An Account?")}  - - {t('sign_up', 'Sign Up')} - -

-

- - {t('forgot_password', 'Forgot password')} - -

diff --git a/apps/frontend/src/components/auth/nayner.auth.button.tsx b/apps/frontend/src/components/auth/nayner.auth.button.tsx index 68cd56675d..da450b6df6 100644 --- a/apps/frontend/src/components/auth/nayner.auth.button.tsx +++ b/apps/frontend/src/components/auth/nayner.auth.button.tsx @@ -81,5 +81,5 @@ export const NeynarAuthButton: FC<{ document.removeEventListener('mousedown', handleOutsideClick); }; }, [showModal, handleOutsideClick]); - return
{children}
; + return
{children}
; }; diff --git a/apps/frontend/src/components/auth/providers/farcaster.provider.tsx b/apps/frontend/src/components/auth/providers/farcaster.provider.tsx index 07b9408a73..d3ae3399f0 100644 --- a/apps/frontend/src/components/auth/providers/farcaster.provider.tsx +++ b/apps/frontend/src/components/auth/providers/farcaster.provider.tsx @@ -26,29 +26,32 @@ export const ButtonCaster: FC<{ >
- - - + + + + + + + + + -
{t('continue_with_farcaster', 'Continue with Farcaster')}
+
{t('farcaster', 'Farcaster')}
diff --git a/apps/frontend/src/components/auth/providers/google.provider.tsx b/apps/frontend/src/components/auth/providers/google.provider.tsx index 0aeb306970..afa4066687 100644 --- a/apps/frontend/src/components/auth/providers/google.provider.tsx +++ b/apps/frontend/src/components/auth/providers/google.provider.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useCallback } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; @@ -11,7 +13,7 @@ export const GoogleProvider = () => { return (
{ />
-
{t('continue_with_google', 'Continue with Google')}
+
{t('google', 'Google')}
); }; diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx index 7d4ed7bb96..6e293fe02f 100644 --- a/apps/frontend/src/components/auth/providers/oauth.provider.tsx +++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx @@ -26,7 +26,7 @@ export const OauthProvider = () => { return (
{ const t = useT(); return (
+ - {t('continue_with_your_wallet', 'Continue with your Wallet')} +
Wallet
); }; diff --git a/apps/frontend/src/components/auth/providers/wallet.provider.tsx b/apps/frontend/src/components/auth/providers/wallet.provider.tsx index 16f5a2eea1..83585dde56 100644 --- a/apps/frontend/src/components/auth/providers/wallet.provider.tsx +++ b/apps/frontend/src/components/auth/providers/wallet.provider.tsx @@ -174,7 +174,7 @@ const InnerWallet = () => { } }, [buttonState]); return ( -
walletModal.setVisible(true)}> +
walletModal.setVisible(true)} className="flex-1">
); diff --git a/apps/frontend/src/components/auth/register.tsx b/apps/frontend/src/components/auth/register.tsx index 13d925d28f..592c354e1a 100644 --- a/apps/frontend/src/components/auth/register.tsx +++ b/apps/frontend/src/components/auth/register.tsx @@ -144,97 +144,118 @@ export function RegisterAfter({ }; return ( -
-
-

- {t('sign_up', 'Sign Up')} -

-
- {!isAfterProvider && - (!isGeneral ? ( - - ) : ( -
- {genericOauth && isGeneral ? ( - + +
+
+

+ {t('sign_up', 'Sign Up')} +

+
+
{t('continue_with', 'Continue With')}
+
+ {!isAfterProvider && + (!isGeneral ? ( + ) : ( - - )} - {!!neynarClientId && } - {billingEnabled && } -
- ))} - {!isAfterProvider && ( -
-
-
-
{t('or', 'OR')}
+
+ {genericOauth && isGeneral ? ( + + ) : ( + + )} + {!!neynarClientId && } + {billingEnabled && } +
+ ))} + {!isAfterProvider && ( +
+
+
+
+ {t('or', 'or')} +
+
+
+ )} +
+
+ {!isAfterProvider && ( + <> + + + + )} + +
+
+ {t( + 'by_registering_you_agree_to_our', + 'By registering you agree to our' + )} +   + + {t('terms_of_service', 'Terms of Service')} + +   + {t('and', 'and')}  + + {t('privacy_policy', 'Privacy Policy')} + +   +
+
+
+ +
+

+ {t('already_have_an_account', 'Already Have An Account?')} +   + + {t('sign_in', 'Sign In')} + +

+
- )} -
- {!isAfterProvider && ( - <> - - - - )} - -
-
- {t( - 'by_registering_you_agree_to_our', - 'By registering you agree to our' - )}  - - {t('terms_of_service', 'Terms of Service')} -   - {t('and', 'and')}  - - {t('privacy_policy', 'Privacy Policy')} -   -
-
-
- -
-

- {t('already_have_an_account', 'Already Have An Account?')}  - - {t('sign_in', 'Sign In')} - -

diff --git a/apps/frontend/src/components/auth/testimonial.component.tsx b/apps/frontend/src/components/auth/testimonial.component.tsx new file mode 100644 index 0000000000..ca22fb03a0 --- /dev/null +++ b/apps/frontend/src/components/auth/testimonial.component.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { + testimonials1, + testimonials2, +} from '@gitroom/react/helpers/testomonials'; +import { Testimonial } from '@gitroom/frontend/components/auth/testimonial'; + +export const TestimonialComponent = () => { + return ( +
+
+
+
+
+
+ {[1, 2].flatMap((p) => + testimonials1.flatMap((a) => ( +
+ +
+ )) + )} +
+
+ {[1, 2].flatMap((p) => + testimonials2.flatMap((a) => ( +
+ +
+ )) + )} +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/auth/testimonial.tsx b/apps/frontend/src/components/auth/testimonial.tsx new file mode 100644 index 0000000000..a9eceddf65 --- /dev/null +++ b/apps/frontend/src/components/auth/testimonial.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import Image from 'next/image'; + +export const Testimonial: FC<{ + picture: string; + name: string; + description: string; + content: any; +}> = ({ content, description, name, picture }) => { + return ( +
+ {/* Header */} +
+
+ {name} +
+ +
+
{name}
+
+ {description} +
+
+
+ + {/* Content */} +
+ {typeof content === 'string' ? content.replace(/\\n/g, '\n') : content} +
+
+ ); +}; diff --git a/apps/frontend/src/components/autopost/autopost.tsx b/apps/frontend/src/components/autopost/autopost.tsx index bfda9118a7..e4f2ce130f 100644 --- a/apps/frontend/src/components/autopost/autopost.tsx +++ b/apps/frontend/src/components/autopost/autopost.tsx @@ -1,9 +1,10 @@ +'use client'; + import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { Button } from '@gitroom/react/form/button'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; -import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import { Input } from '@gitroom/react/form/input'; import { FormProvider, useForm } from 'react-hook-form'; import { array, boolean, object, string } from 'yup'; @@ -28,7 +29,7 @@ export const Autopost: FC = () => { const addWebhook = useCallback( (data?: any) => () => { modal.openModal({ - title: data ? 'Edit Autopost' : 'Add Autopost', + title: data ? t('edit_autopost', 'Edit Autopost') : t('add_autopost_title', 'Add Autopost'), withCloseButton: true, children: , }); @@ -50,7 +51,7 @@ export const Autopost: FC = () => { method: 'DELETE', }); mutate(); - toaster.show('Webhook deleted successfully', 'success'); + toaster.show(t('webhook_deleted_successfully', 'Webhook deleted successfully'), 'success'); } }, [] @@ -142,33 +143,33 @@ const details = object().shape({ }) ), }); -const options = [ +const getOptions = (t: (key: string, fallback: string) => string) => [ { - label: 'All integrations', + label: t('all_integrations', 'All integrations'), value: 'all', }, { - label: 'Specific integrations', + label: t('specific_integrations', 'Specific integrations'), value: 'specific', }, ]; -const optionsChoose = [ +const getOptionsChoose = (t: (key: string, fallback: string) => string) => [ { - label: 'Yes', + label: t('yes', 'Yes'), value: true, }, { - label: 'No', + label: t('no', 'No'), value: false, }, ]; -const postImmediately = [ +const getPostImmediately = (t: (key: string, fallback: string) => string) => [ { - label: 'Post on the next available slot', + label: t('post_on_next_available_slot', 'Post on the next available slot'), value: true, }, { - label: 'Post Immediately', + label: t('post_immediately', 'Post Immediately'), value: false, }, ]; @@ -178,6 +179,10 @@ export const AddOrEditWebhook: FC<{ }> = (props) => { const { data, reload } = props; const fetch = useFetch(); + const t = useT(); + const options = getOptions(t); + const optionsChoose = getOptionsChoose(t); + const postImmediately = getPostImmediately(t); const [allIntegrations, setAllIntegrations] = useState( (JSON.parse(data?.integrations || '[]')?.length || 0) > 0 ? options[1] @@ -255,8 +260,8 @@ export const AddOrEditWebhook: FC<{ }); toast.show( data?.id - ? 'Autopost updated successfully' - : 'Autopost added successfully', + ? t('autopost_updated_successfully', 'Autopost updated successfully') + : t('autopost_added_successfully', 'Autopost added successfully'), 'success' ); modal.closeAll(); @@ -277,10 +282,10 @@ export const AddOrEditWebhook: FC<{ ).json(); if (!success) { setValid(''); - toast.show('Could not use this RSS feed', 'warning'); + toast.show(t('could_not_use_rss_feed', 'Could not use this RSS feed'), 'warning'); return; } - toast.show('RSS valid!', 'success'); + toast.show(t('rss_valid', 'RSS valid!'), 'success'); setValid(url); setLastUrl(newUrl); } catch (e: any) { @@ -288,8 +293,6 @@ export const AddOrEditWebhook: FC<{ } }, []); - const t = useT(); - return (
@@ -360,7 +363,7 @@ export const AddOrEditWebhook: FC<{ onChange={(e) => { form.setValue('content', e.target.value); }} - placeholder="Write your post..." + placeholder={t('write_your_post_placeholder', 'Write your post...')} autosuggestionsConfig={{ textareaPurpose: `Assist me in writing social media post`, chatApiConfigs: {}, diff --git a/apps/frontend/src/components/billing/embedded.billing.tsx b/apps/frontend/src/components/billing/embedded.billing.tsx new file mode 100644 index 0000000000..713c57e895 --- /dev/null +++ b/apps/frontend/src/components/billing/embedded.billing.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { Stripe } from '@stripe/stripe-js'; + +import { FC, useEffect, useState } from 'react'; +import { + PaymentElement, + BillingAddressElement, + CheckoutProvider, + useCheckout, +} from '@stripe/react-stripe-js/checkout'; +import { modeEmitter } from '@gitroom/frontend/components/layout/mode.component'; +import useCookie from 'react-use-cookie'; +import { Button } from '@gitroom/react/form/button'; +import dayjs from 'dayjs'; +import Image from 'next/image'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const EmbeddedBilling: FC<{ + stripe: Promise; + secret: string; +}> = ({ stripe, secret }) => { + const [saveSecret, setSaveSecret] = useState(secret); + const [loading, setLoading] = useState(false); + const [mode, setMode] = useCookie('mode', 'dark'); + + useEffect(() => { + modeEmitter.on('mode', (value) => { + setMode(value); + setLoading(true); + }); + + return () => { + modeEmitter.removeAllListeners(); + }; + }, []); + + useEffect(() => { + if (loading) { + setLoading(false); + } + }, [loading]); + + useEffect(() => { + if (secret && saveSecret !== secret) { + setSaveSecret(secret); + } + }, [secret, setSaveSecret]); + + if (saveSecret !== secret || loading) { + return null; + } + + return ( +
+ + + +
+ ); +}; + +const FormWrapper = () => { + const checkoutState = useCheckout(); + const toaster = useToaster(); + const [loading, setLoading] = useState(false); + + if (checkoutState.type === 'loading' || checkoutState.type === 'error') { + return null; + } + + const handleSubmit = async (e: any) => { + e.preventDefault(); + setLoading(true); + + const { checkout } = checkoutState; + + const confirmResult = await checkout.confirm(); + + if (confirmResult.type === 'error') { + toaster.show(confirmResult.error.message, 'warning'); + } + + setLoading(false); + }; + + return ( + + + + + ); +}; + +const StripeInputs = () => { + const checkout = useCheckout(); + const t = useT(); + return ( + <> +
+

+ {checkout.type === 'loading' + ? '' + : t('billing_billing_address', 'Billing Address')} +

+ +
+
+

+ {checkout.type === 'loading' ? '' : t('billing_payment', 'Payment')} +

+ + {checkout.type === 'loading' ? null : ( +
+
+ {t( + 'billing_powered_by_stripe', + 'Secure payments processed by' + )} +
+ + + +
+ )} +
+ + ); +}; + +const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => { + const checkout = useCheckout(); + const t = useT(); + if (checkout.type === 'loading' || checkout.type === 'error') { + return null; + } + + return ( +
+
+ {checkout.checkout.recurring?.trial?.trialEnd ? ( +
+ {t('billing_your_7_day_trial_is', 'Your 7-day trial is')}{' '} + + {t('billing_100_percent_free', '100% free')} + {' '} + {t('billing_ending', 'ending')}{' '} +
+ + {dayjs( + checkout.checkout.recurring?.trial?.trialEnd * 1000 + ).format('MMMM D, YYYY')}{' '} + —{' '} + + + {t('billing_cancel_anytime_short', 'Cancel anytime.')} + +
+ ) : null} +
+ +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/billing/faq.component.tsx b/apps/frontend/src/components/billing/faq.component.tsx index 157148c3c8..b5eac193ed 100644 --- a/apps/frontend/src/components/billing/faq.component.tsx +++ b/apps/frontend/src/components/billing/faq.component.tsx @@ -69,7 +69,7 @@ export const FAQSection: FC<{ }, [show]); return (
-
 {
             e.stopPropagation();
           }}
@@ -134,7 +134,7 @@ export const FAQComponent: FC = () => {
   const list = useFaqList();
   return (
     
-

+

{t('frequently_asked_questions', 'Frequently Asked Questions')}

diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx new file mode 100644 index 0000000000..14316ae007 --- /dev/null +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -0,0 +1,355 @@ +'use client'; + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { loadStripe, Stripe } from '@stripe/stripe-js'; +import { useSearchParams } from 'next/navigation'; +import { OrganizationSelector } from '@gitroom/frontend/components/layout/organization.selector'; +import { LanguageComponent } from '@gitroom/frontend/components/layout/language.component'; +import { AttachToFeedbackIcon } from '@gitroom/frontend/components/new-layout/sentry.feedback.component'; +import NotificationComponent from '@gitroom/frontend/components/notifications/notification.component'; +import dynamic from 'next/dynamic'; +import { LogoTextComponent } from '@gitroom/frontend/components/ui/logo-text.component'; +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; +import { capitalize } from 'lodash'; +import clsx from 'clsx'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; +import { CheckIconComponent } from '@gitroom/frontend/components/ui/check.icon.component'; +import { + FAQComponent, + FAQSection, +} from '@gitroom/frontend/components/billing/faq.component'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { useDubClickId } from '@gitroom/frontend/components/layout/dubAnalytics'; + +const ModeComponent = dynamic( + () => import('@gitroom/frontend/components/layout/mode.component'), + { + ssr: false, + } +); + +const EmbeddedBilling = dynamic( + () => + import('@gitroom/frontend/components/billing/embedded.billing').then( + (mod) => mod.EmbeddedBilling + ), + { + ssr: false, + } +); + +export const FirstBillingComponent = () => { + const { stripeClient } = useVariables(); + const user = useUser(); + const dub = useDubClickId(); + const [stripe, setStripe] = useState>(null); + const [tier, setTier] = useState('STANDARD'); + const [period, setPeriod] = useState('MONTHLY'); + const fetch = useFetch(); + const t = useT(); + + useEffect(() => { + setStripe(loadStripe(stripeClient)); + }, []); + + const loadCheckout = useCallback(async () => { + return ( + await fetch('/billing/embedded', { + method: 'POST', + body: JSON.stringify({ + billing: tier, + period: period, + ...(dub ? { dub } : {}), + }), + }) + ).json(); + }, [tier, period]); + + const { data, isLoading } = useSWR( + `/billing-${tier}-${period}`, + loadCheckout, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + refreshWhenOffline: false, + refreshWhenHidden: false, + } + ); + + const price = useMemo( + () => Object.entries(pricing).filter(([key, value]) => key !== 'FREE'), + [] + ); + + const JoinOver = () => { + return ( + <> +
+ {t('billing_join_over', 'Join Over')}{' '} + + {t('billing_entrepreneurs_count', '18,000+ Entrepreneurs')} + {' '} + {t('billing_who_use', 'who use')}{' '} + {t( + 'billing_postiz_grow_social', + 'Postiz To Grow Their Social Presence' + )} +
+ + {!!user?.allowTrial && ( +
+
+
+ +
+
{t('billing_no_risk_trial', '100% No-Risk Free Trial')}
+
+
+
+ +
+
+ {t( + 'billing_pay_nothing_7_days', + 'Pay NOTHING for the first 7-days' + )} +
+
+
+
+ +
+
+ {t('billing_cancel_anytime', 'Cancel anytime, hassle-free')} +
+
+
+ )} + + ); + }; + + return ( +
+
+
+ +
+
+
+ +
+ +
+
+ +
+ + +
+
+
+
+
+
+ +
+ {!isLoading && data && stripe ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ +
+
+
+ {t('billing_choose_plan', 'Choose a Plan')} +
+
+
setPeriod('MONTHLY')} + > + {t('billing_monthly', 'Monthly')} +
+
setPeriod('YEARLY')} + > +
{t('billing_yearly', 'Yearly')}
+
+ {t('billing_20_percent_off', '20% Off')} +
+
+
+
+
+ {price.map( + ([key, value]) => ( +
setTier(key)} + key={key} + className={clsx( + 'cursor-pointer select-none w-[266px] h-[138px] tablet:w-full tablet:h-[124px] p-[24px] tablet:p-[15px] rounded-[20px] flex flex-col', + key === tier + ? 'border-[1.5px] border-[#618DFF]' + : 'border-[1.5px] border-newColColor' + )} + > +
+ {capitalize(key)} +
+
+ + $ + { + value[ + period === 'MONTHLY' ? 'month_price' : 'year_price' + ] + } + {' '} + {period === 'MONTHLY' + ? t('billing_per_month', '/ month') + : t('billing_per_year', '/ year')} +
+
+ ), + [] + )} +
+
+
+ {t('billing_features', 'Features')} +
+ +
+
+
+
+
+ ); +}; + +type FeatureItem = { + key: string; + defaultValue: string; + prefix?: string | number; +}; + +export const BillingFeatures: FC<{ tier: string }> = ({ tier }) => { + const t = useT(); + const features = useMemo(() => { + const currentPricing = pricing[tier]; + const channelsOr = currentPricing.channel; + const list: FeatureItem[] = []; + + list.push({ + key: channelsOr === 1 ? 'billing_channel' : 'billing_channels', + defaultValue: channelsOr === 1 ? 'channel' : 'channels', + prefix: channelsOr, + }); + + list.push({ + key: 'billing_posts_per_month', + defaultValue: 'posts per month', + prefix: + currentPricing.posts_per_month > 10000 + ? 'unlimited' + : currentPricing.posts_per_month, + }); + + if (currentPricing.team_members) { + list.push({ + key: 'billing_unlimited_team_members', + defaultValue: 'Unlimited team members', + }); + } + if (currentPricing?.ai) { + list.push({ + key: 'billing_ai_auto_complete', + defaultValue: 'AI auto-complete', + }); + list.push({ key: 'billing_ai_copilots', defaultValue: 'AI copilots' }); + list.push({ + key: 'billing_ai_autocomplete', + defaultValue: 'AI Autocomplete', + }); + } + list.push({ + key: 'billing_advanced_picture_editor', + defaultValue: 'Advanced Picture Editor', + }); + if (currentPricing?.image_generator) { + list.push({ + key: 'billing_ai_images_per_month', + defaultValue: 'AI Images per month', + prefix: currentPricing?.image_generation_count, + }); + } + if (currentPricing?.generate_videos) { + list.push({ + key: 'billing_ai_videos_per_month', + defaultValue: 'AI Videos per month', + prefix: currentPricing?.generate_videos, + }); + } + return list; + }, [tier]); + + const renderFeature = (feature: FeatureItem) => { + const translatedText = t(feature.key, feature.defaultValue); + if (feature.prefix === 'unlimited') { + return `${t('billing_unlimited', 'Unlimited')} ${translatedText}`; + } + if (feature.prefix !== undefined) { + return `${feature.prefix} ${translatedText}`; + } + return translatedText; + }; + + return ( +
+ {features.map((feature) => ( +
+
+ + + +
+
{renderFeature(feature)}
+
+ ))} +
+ ); +}; diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index a52b6e6331..aae4f12043 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -18,17 +18,16 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { useRouter, useSearchParams } from 'next/navigation'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; -import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import { Textarea } from '@gitroom/react/form/textarea'; import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events'; import { useUtmUrl } from '@gitroom/helpers/utils/utm.saver'; -import { useTolt } from '@gitroom/frontend/components/layout/tolt.script'; import { useTrack } from '@gitroom/react/helpers/use.track'; import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; import { PurchaseCrypto } from '@gitroom/frontend/components/billing/purchase.crypto'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { FinishTrial } from '@gitroom/frontend/components/billing/finish.trial'; import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; +import { useDubClickId } from '@gitroom/frontend/components/layout/dubAnalytics'; export const Prorate: FC<{ period: 'MONTHLY' | 'YEARLY'; @@ -218,10 +217,10 @@ export const MainBillingComponent: FC<{ const fetch = useFetch(); const toast = useToaster(); const user = useUser(); + const dub = useDubClickId(); const modal = useModals(); const router = useRouter(); const utm = useUtmUrl(); - const tolt = useTolt(); const track = useTrack(); const t = useT(); const queryParams = useSearchParams(); @@ -387,7 +386,7 @@ export const MainBillingComponent: FC<{ period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY', utm, billing, - tolt: tolt(), + ...(dub ? { dub } : {}), }), }) ).json(); diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 8273fc1b55..ec03832a1f 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -114,7 +114,7 @@ export const ApiModal: FC<{ return; } methods.setError('api', { - message: 'Invalid API key', + message: t('invalid_api_key', 'Invalid API key'), }); }, []); @@ -343,7 +343,7 @@ export const AddProviderComponent: FC<{ await fetch(`/integrations/social/${identifier}`) ).json(); modal.openModal({ - title: 'Web3 provider', + title: t('web3_provider', 'Web3 provider'), withCloseButton: false, classNames: { modal: 'bg-transparent text-textColor', @@ -368,7 +368,7 @@ export const AddProviderComponent: FC<{ ) ).json(); if (err) { - toaster.show('Could not connect to the platform', 'warning'); + toaster.show(t('could_not_connect_to_platform', 'Could not connect to the platform'), 'warning'); return; } window.location.href = url; @@ -392,7 +392,7 @@ export const AddProviderComponent: FC<{ if (customFields) { modal.closeAll(); modal.openModal({ - title: 'Add Provider', + title: t('add_provider_title', 'Add Provider'), withCloseButton: false, classNames: { modal: 'bg-transparent text-textColor', diff --git a/apps/frontend/src/components/launches/ai.image.tsx b/apps/frontend/src/components/launches/ai.image.tsx index 9cfa00f8e8..611af14a93 100644 --- a/apps/frontend/src/components/launches/ai.image.tsx +++ b/apps/frontend/src/components/launches/ai.image.tsx @@ -28,7 +28,7 @@ export const AiImage: FC<{ const t = useT(); const { value, onChange } = props; const [loading, setLoading] = useState(false); - const setLocked = useLaunchStore(p => p.setLocked); + const setLocked = useLaunchStore((p) => p.setLocked); const fetch = useFetch(); const generateImage = useCallback( (type: string) => async () => { @@ -59,7 +59,7 @@ ${type} ); return (
-
- +
{value.length >= 30 && !loading && ( -
+
    {list.map((p) => (
  • diff --git a/apps/frontend/src/components/launches/ai.video.tsx b/apps/frontend/src/components/launches/ai.video.tsx index ed0fd2c4e9..5b37f3895c 100644 --- a/apps/frontend/src/components/launches/ai.video.tsx +++ b/apps/frontend/src/components/launches/ai.video.tsx @@ -44,9 +44,9 @@ export const Modal: FC<{ setLocked(true); const customParams = form.getValues(); - if (!await form.trigger()) { + if (!(await form.trigger())) { toaster.show('Please fill all required fields', 'warning'); - return ; + return; } try { const image = await fetch(`/media/generate-video`, { @@ -199,7 +199,7 @@ export const AiVideo: FC<{ /> )}
    -
    - +
{value.length >= 30 && !loading && ( -
+
    {data.map((p: any) => (
  • - +
diff --git a/apps/frontend/src/components/launches/customer.modal.tsx b/apps/frontend/src/components/launches/customer.modal.tsx index 53e47716c2..07b7b83a93 100644 --- a/apps/frontend/src/components/launches/customer.modal.tsx +++ b/apps/frontend/src/components/launches/customer.modal.tsx @@ -1,4 +1,5 @@ -import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +'use client'; + import React, { FC, useCallback, useEffect, useState } from 'react'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; import { Integration } from '@prisma/client'; @@ -56,8 +57,8 @@ export const CustomerModal: FC<{ classNames={{ label: 'text-white', }} - label="Select Customer" - placeholder="Start typing..." + label={t('select_customer_label', 'Select Customer')} + placeholder={t('start_typing', 'Start typing...')} data={data?.map((p: any) => p.name) || []} />
diff --git a/apps/frontend/src/components/launches/filters.tsx b/apps/frontend/src/components/launches/filters.tsx index 6980cbc4d5..43332f1225 100644 --- a/apps/frontend/src/components/launches/filters.tsx +++ b/apps/frontend/src/components/launches/filters.tsx @@ -280,7 +280,7 @@ export const Filters = () => { onClick={setToday} className="hover:text-textItemFocused hover:bg-boxFocused py-[3px] px-[9px] flex justify-center items-center rounded-[8px] transition-all cursor-pointer text-[14px] bg-newBgColorInner border border-newTableBorder" > - Today + {t('today', 'Today')}
diff --git a/apps/frontend/src/components/launches/general.preview.component.tsx b/apps/frontend/src/components/launches/general.preview.component.tsx index 403293c852..049891a76d 100644 --- a/apps/frontend/src/components/launches/general.preview.component.tsx +++ b/apps/frontend/src/components/launches/general.preview.component.tsx @@ -49,7 +49,7 @@ export const GeneralPreviewComponent: FC<{ }); return ( -
+
{renderContent.map((value, index) => (
{ const { integrations, reloadCalendarView } = useCalendar(); const modal = useModals(); @@ -150,15 +152,16 @@ const FirstStep: FC = (props) => { }); setShowStep(''); modal.openModal({ + id: 'add-edit-modal', closeOnClickOutside: false, + removeLayout: true, closeOnEscape: false, withCloseButton: false, - removeLayout: true, askClose: true, + fullScreen: true, classNames: { - modal: 'w-[100%] max-w-[1400px] bg-transparent text-textColor', + modal: 'w-[100%] max-w-[1400px] text-textColor', }, - id: 'add-edit-modal', children: ( ({ @@ -294,9 +297,9 @@ export const GeneratorComponent = () => { if (!user?.tier?.ai) { if ( await deleteDialog( - 'You need to upgrade to use this feature', - 'Move to billing', - 'Payment Required' + t('upgrade_required', 'You need to upgrade to use this feature'), + t('move_to_billing', 'Move to billing'), + t('payment_required', 'Payment Required') ) ) { router.push('/billing'); @@ -304,7 +307,7 @@ export const GeneratorComponent = () => { return; } modal.openModal({ - title: 'Generate Posts', + title: t('generate_posts', 'Generate Posts'), withCloseButton: false, classNames: { modal: 'bg-transparent text-textColor', @@ -329,7 +332,7 @@ export const GeneratorComponent = () => { viewBox="0 0 20 20" fill="none" > - + void; @@ -34,31 +35,20 @@ export const DatePicker: FC<{ ); return (
- {date.format(isUSCitizen() ? 'MM/DD/YYYY hh:mm A' : 'DD/MM/YYYY HH:mm')} +
- - - + {date.format(isUSCitizen() ? 'MM/DD/YYYY hh:mm A' : 'DD/MM/YYYY HH:mm')}
{open && (
e.stopPropagation()} - className="animate-normalFadeDown absolute top-[100%] mt-[16px] end-0 bg-sixth border border-tableBorder text-textColor rounded-[16px] z-[300] p-[16px] flex flex-col" + className="animate-fadeIn absolute bottom-[100%] mb-[16px] start-[50%] -translate-x-[50%] bg-sixth border border-tableBorder text-textColor rounded-[16px] z-[300] p-[16px] flex flex-col" >
)} diff --git a/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx b/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx index 5dba182786..58d21896c3 100644 --- a/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx +++ b/apps/frontend/src/components/launches/helpers/pick.platform.component.tsx @@ -254,7 +254,7 @@ export const PickPlatforms: FC<{ {integration.identifier === 'youtube' ? ( ) : ( diff --git a/apps/frontend/src/components/launches/helpers/top.title.component.tsx b/apps/frontend/src/components/launches/helpers/top.title.component.tsx index 2a44f958f8..48624e60f6 100644 --- a/apps/frontend/src/components/launches/helpers/top.title.component.tsx +++ b/apps/frontend/src/components/launches/helpers/top.title.component.tsx @@ -1,6 +1,7 @@ import { FC, ReactNode } from 'react'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import clsx from 'clsx'; +import { ExpandIcon, CollapseIcon } from '@gitroom/frontend/components/ui/icons'; export const TopTitle: FC<{ title: string; @@ -44,33 +45,9 @@ export const TopTitle: FC<{ {shouldExpend !== undefined && (
{!shouldExpend ? ( - - - + ) : ( - - - + )}
)} diff --git a/apps/frontend/src/components/launches/information.component.tsx b/apps/frontend/src/components/launches/information.component.tsx new file mode 100644 index 0000000000..b65acfa72c --- /dev/null +++ b/apps/frontend/src/components/launches/information.component.tsx @@ -0,0 +1,207 @@ +'use client'; + +import React, { FC, Fragment, useMemo } from 'react'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; +import { useShallow } from 'zustand/react/shallow'; +import clsx from 'clsx'; +import Image from 'next/image'; +import { capitalize } from 'lodash'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +const Valid: FC = () => { + return ( + + + + ); +}; + +const Invalid: FC = () => { + return ( + + + + + + + + + + + ); +}; +export const InformationComponent: FC<{ + chars: Record; + totalChars: number; + totalAllowedChars: number; + isPicture: boolean; +}> = ({ totalChars, totalAllowedChars, chars, isPicture }) => { + const t = useT(); + const { isGlobal, selectedIntegrations, internal } = useLaunchStore( + useShallow((state) => ({ + isGlobal: state.current === 'global', + selectedIntegrations: state.selectedIntegrations, + internal: state.internal, + })) + ); + + const isInternal = useMemo(() => { + if (!isGlobal) { + return []; + } + return selectedIntegrations.map((p) => { + const findIt = internal.find( + (a) => a.integration.id === p.integration.id + ); + + return !!findIt; + }); + }, [isGlobal, internal, selectedIntegrations]); + + const isValid = useMemo(() => { + if (!isPicture && !totalChars) { + return false; + } + + if (totalChars > totalAllowedChars && !isGlobal) { + return false; + } + + if (totalChars <= totalAllowedChars && !isGlobal) { + return true; + } + + if ( + selectedIntegrations.some((p, index) => { + if (isInternal[index]) { + return false; + } + + return totalChars > (chars?.[p.integration.id] || 0); + }) + ) { + return false; + } + + return true; + }, [totalAllowedChars, totalChars, isInternal, isPicture, chars]); + + return ( +
+ {isValid ? : } + + {!isGlobal && ( +
+ {totalChars}/{totalAllowedChars} +
+ )} + {((isGlobal && selectedIntegrations.length) || !isValid) && ( + + + + )} + {((isGlobal && selectedIntegrations.length) || !isValid) && ( + + )} +
+ ); +}; diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index 2a1ea2d7ab..b8940a1e08 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -7,7 +7,6 @@ import { groupBy, orderBy } from 'lodash'; import { CalendarWeekProvider } from '@gitroom/frontend/components/launches/calendar.context'; import { Filters } from '@gitroom/frontend/components/launches/filters'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; -import useSWR from 'swr'; import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; import clsx from 'clsx'; import { useUser } from '../layout/user.context'; @@ -234,6 +233,7 @@ export const MenuComponent: FC< collapsed, } = props; const user = useUser(); + const t = useT(); const [collected, drag, dragPreview] = useDrag(() => ({ type: 'menu', item: { @@ -247,7 +247,7 @@ export const MenuComponent: FC< {...(integration.refreshNeeded && { onClick: refreshChannel(integration), 'data-tooltip-id': 'tooltip', - 'data-tooltip-content': 'Channel disconnected, click to reconnect.', + 'data-tooltip-content': t('channel_disconnected_click_to_reconnect', 'Channel disconnected, click to reconnect.'), })} {...(collapsed ? { @@ -288,7 +288,7 @@ export const MenuComponent: FC< { fireEvents('channel_added'); window?.opener?.postMessage( { - msg: 'Channel added', + msg: t('channel_added', 'Channel added'), success: true, }, '*' @@ -499,7 +499,7 @@ export const LaunchesComponent = () => { >
diff --git a/apps/frontend/src/components/launches/layout.standalone.tsx b/apps/frontend/src/components/launches/layout.standalone.tsx index 275bea5f13..746a35de5e 100644 --- a/apps/frontend/src/components/launches/layout.standalone.tsx +++ b/apps/frontend/src/components/launches/layout.standalone.tsx @@ -15,7 +15,7 @@ export const AppLayout = ({ children }: { children: ReactNode }) => { }, [params]); return (
+ + + ); }; diff --git a/apps/frontend/src/components/new-launch/add.post.button.tsx b/apps/frontend/src/components/new-launch/add.post.button.tsx index 1a2ca5b7ad..c43c991e30 100644 --- a/apps/frontend/src/components/new-launch/add.post.button.tsx +++ b/apps/frontend/src/components/new-launch/add.post.button.tsx @@ -13,22 +13,25 @@ export const AddPostButton: FC<{ const t = useT(); return ( -
- +
); }; diff --git a/apps/frontend/src/components/new-launch/bold.text.tsx b/apps/frontend/src/components/new-launch/bold.text.tsx index d0478a8194..76ef1d80f0 100644 --- a/apps/frontend/src/components/new-launch/bold.text.tsx +++ b/apps/frontend/src/components/new-launch/bold.text.tsx @@ -81,32 +81,25 @@ export const BoldText: FC<{ }; return (
- - - - - - - - +
); diff --git a/apps/frontend/src/components/new-launch/bullets.component.tsx b/apps/frontend/src/components/new-launch/bullets.component.tsx index 454162e194..24cb334476 100644 --- a/apps/frontend/src/components/new-launch/bullets.component.tsx +++ b/apps/frontend/src/components/new-launch/bullets.component.tsx @@ -11,19 +11,24 @@ export const Bullets: FC<{ }; return (
diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index 6d6c9b090f..2842b3d0df 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -10,10 +10,8 @@ import React, { ClipboardEvent, forwardRef, useImperativeHandle, - Fragment, } from 'react'; import clsx from 'clsx'; -import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import EmojiPicker from 'emoji-picker-react'; import { Theme } from 'emoji-picker-react'; @@ -41,7 +39,6 @@ import { EditorContent, Extension, mergeAttributes, - Node, } from '@tiptap/react'; import Document from '@tiptap/extension-document'; import Bold from '@tiptap/extension-bold'; @@ -58,7 +55,15 @@ import Mention from '@tiptap/extension-mention'; import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { AComponent } from '@gitroom/frontend/components/new-launch/a.component'; -import { capitalize } from 'lodash'; +import { Placeholder } from '@tiptap/extensions'; +import { InformationComponent } from '@gitroom/frontend/components/launches/information.component'; +import { + LockIcon, + ConnectionLineIcon, + ResetIcon, + TrashIcon, + EmojiIcon, +} from '@gitroom/frontend/components/ui/icons'; const InterceptBoldShortcut = Extension.create({ name: 'preventBoldWithUnderline', @@ -91,7 +96,8 @@ const InterceptUnderlineShortcut = Extension.create({ export const EditorWrapper: FC<{ totalPosts: number; value: string; -}> = (props) => { +}> = () => { + const t = useT(); const { setGlobalValueText, setInternalValueText, @@ -121,11 +127,13 @@ export const EditorWrapper: FC<{ setLoadedState, selectedIntegration, chars, + comments, } = useLaunchStore( useShallow((state) => ({ internal: state.internal.find((p) => p.integration.id === state.current), internalFromAll: state.integrations.find((p) => p.id === state.current), global: state.global, + comments: state.comments, current: state.current, addRemoveInternal: state.addRemoveInternal, dummy: state.dummy, @@ -267,17 +275,23 @@ export const EditorWrapper: FC<{ const goBackToGlobal = useCallback(async () => { if ( await deleteDialog( - 'This action is irreversible. Are you sure you want to go back to global mode?', - 'Yes, go back to global mode' + t('are_you_sure_go_back_to_global_mode', 'This action is irreversible. Are you sure you want to go back to global mode?'), + t('yes_go_back_to_global_mode', 'Yes, go back to global mode') ) ) { setLoaded(false); addRemoveInternal(current); } - }, [addRemoveInternal, current]); + }, [addRemoveInternal, current, t]); const addValue = useCallback( (index: number) => () => { + setTimeout(() => { + // scroll the the bottom + document.querySelector('#social-content').scrollTo({ + top: document.querySelector('#social-content').scrollHeight, + }); + }, 20); if (internal) { return addInternalValue(index, current, [ { @@ -303,8 +317,8 @@ export const EditorWrapper: FC<{ (index: number) => async () => { if ( !(await deleteDialog( - 'Are you sure you want to delete this post?', - 'Yes, delete it!' + t('are_you_sure_delete_this_post', 'Are you sure you want to delete this post?'), + t('yes_delete_it', 'Yes, delete it!') )) ) { return; @@ -318,7 +332,7 @@ export const EditorWrapper: FC<{ deleteGlobalValue(index); setLoaded(false); }, - [current, global, internal] + [current, global, internal, t] ); if (!loaded || !loadedState) { @@ -326,31 +340,77 @@ export const EditorWrapper: FC<{ } return ( -
+
+ {isCreateSet && current !== 'global' && ( + <> +
+
+
+ +
+
+
+
+ {t('cant_edit_networks_when_creating_set', "You can't edit networks when creating a set")} +
+
+
+ + )} + {!canEdit && !isCreateSet && ( + <> +
{ + setLoaded(false); + addRemoveInternal(current); + }} + className="text-center absolute w-full h-full p-[20px] left-0 top-0 items-center justify-center flex z-[101] flex-col gap-[16px]" + > +
+
+ +
+
+
+
+ {t('click_to_exit_global_editing', 'Click this button to exit global editing and customize the post for this channel')} +
+
+
+ {t('edit_content', 'Edit content')} +
+
+
+
+ + )} {items.map((g, index) => ( -
- {!canEdit && !isCreateSet && ( -
{ - if (index !== 0) { - return; - } - - setLoaded(false); - addRemoveInternal(current); - }} - className="select-none cursor-pointer absolute w-full h-full left-0 top-0 bg-red-600/10 z-[100]" - > - {index === 0 && ( -
- Edit +
0) || (!comments && index > 0)) && 'hidden' + )} + > +
+
+ {index > 0 && ( +
+
)} -
- )} -
-
- {canEdit ? ( - - ) : ( -
- )} + {((canEdit && items.length - 1 === index) || !comments) ? ( +
+
+ {comments && ( + + )} +
+ {!!internal && !existingData?.integration && ( +
+
+
+
+ {t('editing_a_specific_network', 'Editing a Specific Network')} +
+
+
+
+ +
+
+ {t('back_to_global', 'Back to global')} +
+
+
+ )} +
+ ) : null} } />
-
- - {index === 0 && - current !== 'global' && - canEdit && - !existingData.integration && ( - + + {items.length > 1 && ( + - - - )} - {items.length > 1 && ( - - - - )} -
+ )} +
+ )}
))} @@ -448,6 +504,7 @@ export const Editor: FC<{ appendImages?: (value: any[]) => void; autoComplete?: boolean; validateChars?: boolean; + comments: boolean | 'no-media'; identifier?: string; totalChars?: number; selectedIntegration: SelectedIntegrations[]; @@ -461,17 +518,14 @@ export const Editor: FC<{ pictures, setImages, num, - validateChars, identifier, appendImages, - selectedIntegration, dummy, chars, childButton, + comments, } = props; - const user = useUser(); const [id] = useState(makeId(10)); - const newRef = useRef(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const t = useT(); const editorRef = useRef(); @@ -495,6 +549,9 @@ export const Editor: FC<{ const paste = useCallback( async (event: ClipboardEvent | File[]) => { + if (num > 0 && comments === 'no-media') { + return; + } // @ts-ignore const clipboardItems = event.clipboardData?.items; if (!clipboardItems) { @@ -511,10 +568,13 @@ export const Editor: FC<{ } } }, - [uppy] + [uppy, num, comments] ); - const { getRootProps, isDragActive } = useDropzone({ onDrop }); + const { getRootProps, isDragActive } = useDropzone({ + onDrop, + noDrag: num > 0 && comments === 'no-media', + }); const valueWithoutHtml = useMemo(() => { return stripHtmlValidation('normal', props.value || '', true); @@ -528,73 +588,49 @@ export const Editor: FC<{ [props.value, id] ); + const [loadedEditor, setLoadedEditor] = useState(editorType); + const [showEditor, setShowEditor] = useState(true); + useEffect(() => { + if (editorType === loadedEditor) { + return; + } + setLoadedEditor(editorType); + setShowEditor(false); + }, [editorType]); + + useEffect(() => { + if (showEditor) { + return; + } + setTimeout(() => { + setShowEditor(true); + }, 20); + }, [showEditor]); + + if (!showEditor) { + return null; + } + return ( -
-
-
- - - - {(editorType === 'markdown' || editorType === 'html') && - identifier !== 'telegram' && ( - <> - - - - - )} -
setEmojiPickerOpen(!emojiPickerOpen)} - > - {'\uD83D\uDE00'} -
-
-
- { - addText(e.emoji); - setEmojiPickerOpen(false); - }} - open={emojiPickerOpen} - /> -
-
-
-
- {validateChars && - props.value.length === 0 && - pictures?.length === 0 && ( -
- Your post should have at least one character or one image. +
+
0 && '!rounded-bs-[0]' + )} + id={id} + > +
+
+
+ {t('drop_files_here_to_upload', 'Drop your files here to upload')}
- )} -
-
- Drop your files here to upload -
-
+
-
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} - > - + /> +
+
+ +
-
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} - > + /> +
{setImages && ( 0 && comments === 'no-media'} allData={allValues} text={valueWithoutHtml} label={t('attachments', 'Attachments')} @@ -644,6 +683,75 @@ export const Editor: FC<{ value={props.pictures} dummy={dummy} name="image" + information={ + 0} + chars={chars} + totalChars={valueWithoutHtml.length} + totalAllowedChars={props.totalChars} + /> + } + toolBar={ +
+ + + + {(editorType === 'markdown' || editorType === 'html') && + identifier !== 'telegram' && ( + <> + + + + + )} +
setEmojiPickerOpen(!emojiPickerOpen)} + > + +
+
+
1 + ? 'top-[35px]' + : 'bottom-[35px]' + )} + > + { + addText(e.emoji); + setEmojiPickerOpen(false); + }} + open={emojiPickerOpen} + /> +
+
+
+ } onChange={(value) => { setImages(value.target.value); }} @@ -652,52 +760,10 @@ export const Editor: FC<{ /> )}
+
{childButton}
-
-
{childButton}
-
- {(props?.totalChars || 0) > 0 ? ( -
props.totalChars && '!text-red-500' - )} - > - {valueWithoutHtml.length}/{props.totalChars} -
- ) : ( -
- {selectedIntegration?.map((p) => ( - -
chars?.[p.integration.id] && - '!text-red-500' - } - > - {p.integration.name} ({capitalize(p.integration.identifier)} - ): -
-
chars?.[p.integration.id] && - '!text-red-500' - } - > - {valueWithoutHtml.length}/{chars?.[p.integration.id]} -
-
- ))} -
- )} -
-
); }; @@ -711,7 +777,9 @@ export const OnlyEditor = forwardRef< paste?: (event: ClipboardEvent | File[]) => void; } >(({ editorType, value, onChange, paste }, ref) => { + const t = useT(); const fetch = useFetch(); + const { internal } = useLaunchStore( useShallow((state) => ({ internal: state.internal.find((p) => p.integration.id === state.current), @@ -759,6 +827,10 @@ export const OnlyEditor = forwardRef< InterceptUnderlineShortcut, BulletList, ListItem, + Placeholder.configure({ + placeholder: t('write_something', 'Write something …'), + emptyEditorClass: 'is-editor-empty', + }), ...(editorType === 'html' || editorType === 'markdown' ? [ Link.configure({ diff --git a/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx b/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx index b15ec572cc..b6e9522765 100644 --- a/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx +++ b/apps/frontend/src/components/new-launch/finisher/thread.finisher.tsx @@ -50,6 +50,7 @@ export const ThreadFinisher = () => {
setValue('thread_finisher', val)} diff --git a/apps/frontend/src/components/new-launch/heading.component.tsx b/apps/frontend/src/components/new-launch/heading.component.tsx index 26985fe033..99ba129cf5 100644 --- a/apps/frontend/src/components/new-launch/heading.component.tsx +++ b/apps/frontend/src/components/new-launch/heading.component.tsx @@ -13,20 +13,27 @@ export const HeadingComponent: FC<{ }; return ( -
+
-
+
= (props) => { const [loading, setLoading] = useState(false); const toaster = useToaster(); const modal = useModals(); + const [showSettings, setShowSettings] = useState(false); const { addEditSets, mutate, customClose, dummy } = props; @@ -58,12 +72,14 @@ export const ManageModal: FC = (props) => { integrations, setSelectedIntegrations, locked, + current, activateExitButton, } = useLaunchStore( useShallow((state) => ({ hide: state.hide, date: state.date, setDate: state.setDate, + current: state.current, repeater: state.repeater, setRepeater: state.setRepeater, tags: state.tags, @@ -76,24 +92,45 @@ export const ManageModal: FC = (props) => { })) ); - const deletePost = useCallback(async () => { - setLoading(true); - if ( - !(await deleteDialog( - 'Are you sure you want to delete this post?', - 'Yes, delete it!' - )) - ) { - setLoading(false); - return; + const currentIntegrationText = useMemo(() => { + if (current === 'global') { + return ''; } - await fetch(`/posts/${existingData.group}`, { - method: 'DELETE', - }); - mutate(); - modal.closeAll(); - return; - }, [existingData, mutate, modal]); + + const currentIntegration = integrations.find((p) => p.id === current)!; + + return ( +
+
+ {currentIntegration.identifier} + +
+
{currentIntegration.name} {t('channel_settings', 'Settings')}
+
+ ); + }, [current]); + + const changeCustomer = useCallback( + (customer: string) => { + const neededIntegrations = integrations.filter( + (p) => p?.customer?.id === customer + ); + setSelectedIntegrations( + neededIntegrations.map((p) => ({ + settings: {}, + selectedIntegrations: p, + })) + ); + }, + [integrations] + ); const askClose = useCallback(async () => { if (!activateExitButton || dummy) { @@ -117,20 +154,24 @@ export const ManageModal: FC = (props) => { } }, [activateExitButton, dummy]); - const changeCustomer = useCallback( - (customer: string) => { - const neededIntegrations = integrations.filter( - (p) => p?.customer?.id === customer - ); - setSelectedIntegrations( - neededIntegrations.map((p) => ({ - settings: {}, - selectedIntegrations: p, - })) - ); - }, - [integrations] - ); + const deletePost = useCallback(async () => { + setLoading(true); + if ( + !(await deleteDialog( + t('are_you_sure_you_want_to_delete_post', 'Are you sure you want to delete this post?'), + t('yes_delete_it', 'Yes, delete it!') + )) + ) { + setLoading(false); + return; + } + await fetch(`/posts/${existingData.group}`, { + method: 'DELETE', + }); + mutate(); + modal.closeAll(); + return; + }, [existingData, mutate, modal]); const schedule = useCallback( (type: 'draft' | 'now' | 'schedule') => async () => { @@ -152,7 +193,7 @@ export const ManageModal: FC = (props) => { toaster.show( '' + item.integration.name + - ' Your post should have at least one character or one image.', + ' ' + t('post_needs_content_or_image', 'Your post should have at least one character or one image.'), 'warning' ); setLoading(false); @@ -162,9 +203,10 @@ export const ManageModal: FC = (props) => { for (const item of checkAllValid) { if (item.valid === false) { - toaster.show('Some fields are not valid', 'warning'); + toaster.show(t('please_fix_your_settings', 'Please fix your settings'), 'warning'); item.fix(); setLoading(false); + setShowSettings(true); return; } @@ -177,6 +219,7 @@ export const ManageModal: FC = (props) => { ); item.preview(); setLoading(false); + setShowSettings(false); return; } } @@ -197,7 +240,7 @@ export const ManageModal: FC = (props) => { for (const item of sliceNeeded) { toaster.show( - `${item?.integration?.name} (${item?.integration?.identifier}) post is too long, please fix it`, + `${item?.integration?.name} (${item?.integration?.identifier}) ${t('post_is_too_long', 'post is too long, please fix it')}`, 'warning' ); item.preview(); @@ -222,8 +265,8 @@ export const ManageModal: FC = (props) => { const shortLink = !shortLinkUrl.ask ? false : await deleteDialog( - 'Do you want to shortlink the URLs? it will let you get statistics over clicks', - 'Yes, shortlink it!' + t('shortlink_urls_question', 'Do you want to shortlink the URLs? it will let you get statistics over clicks'), + t('yes_shortlink_it', 'Yes, shortlink it!') ); const group = existingData.group || makeId(10); @@ -284,8 +327,8 @@ export const ManageModal: FC = (props) => { mutate(); toaster.show( !existingData.integration - ? 'Added successfully' - : 'Updated successfully' + ? t('added_successfully', 'Added successfully') + : t('updated_successfully', 'Updated successfully') ); } if (customClose) { @@ -303,197 +346,211 @@ export const ManageModal: FC = (props) => { ); return ( - <> -
-
-
- -
- {!dummy && ( - - )} - -
-
- - -
- {!existingData.integration && } -
-
- {!hide && } +
+
+
+
+
+ {t('create_post_title', 'Create Post')} +
+
+
+
+
+
+ +
+
+ {!dummy && ( + + )} +
+
+
+
{!existingData.integration && }
+
+ {!hide && } +
+
+
-
-
-
-
-
- {!!existingData.integration && ( - - )} - - {!addEditSets && !dummy && ( - - )} - - {addEditSets && ( - - )} - {!addEditSets && ( - - )} +
+
setShowSettings(!showSettings)} + className={clsx( + 'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px]', + showSettings ? '!rounded-b-none' : '' + )} + > +
+ {currentIntegrationText} +
+
+ +
+
+
+
+
+
+
+
+
{t('post_preview', 'Post Preview')}
+
+ +
+
+
+ + + +
+
-
-
- -
+
+
+ {!dummy && ( + { + setTags(e.target.value); + }} + /> + )} + + {!dummy && ( + + )} +
+
+ {existingData?.integration && ( + + )} + + {!addEditSets && ( + + )} + {addEditSets && ( + + )} + {!addEditSets && ( +
+
+ + {!dummy && ( - + )}
- - - - -
-
- + )}
- + -
- + labels={{ + title: t('your_assistant', 'Your Assistant'), + initial: t('assistant_initial_message', 'Hi! I can help you to refine your social media posts.'), + }} + /> +
+ ); +}; + +const Scrollable: FC<{ + className: string; + scrollClasses: string; + children: ReactNode; +}> = ({ className, scrollClasses, children }) => { + const ref = useRef(); + const hasScroll = useHasScroll(ref); + return ( +
+ {children} +
); }; diff --git a/apps/frontend/src/components/new-launch/mention.component.tsx b/apps/frontend/src/components/new-launch/mention.component.tsx index cd1e625209..2ff66a167f 100644 --- a/apps/frontend/src/components/new-launch/mention.component.tsx +++ b/apps/frontend/src/components/new-launch/mention.component.tsx @@ -1,7 +1,8 @@ +'use client'; + import React, { FC, useEffect, useImperativeHandle, useState } from 'react'; import { computePosition, flip, shift } from '@floating-ui/dom'; import { posToDOMRect, ReactRenderer } from '@tiptap/react'; -import { timer } from '@gitroom/helpers/utils/timer'; // Debounce utility for TipTap const debounce = ( @@ -93,7 +94,7 @@ const MentionList: FC = (props: any) => { ) : ( props?.items?.map((item: any, index: any) => (