diff --git a/.github/workflows/container-deploy.yaml b/.github/workflows/container-deploy.yaml index 89f2aba4b4..74297e9b52 100644 --- a/.github/workflows/container-deploy.yaml +++ b/.github/workflows/container-deploy.yaml @@ -31,6 +31,7 @@ on: - 'apps/project-sites/scripts/inspect.js' - 'apps/project-sites/scripts/upload-to-r2.mjs' - 'apps/project-sites/scripts/run-validators.mjs' + - 'apps/project-sites/frontend/**' env: NODE_VERSION: '20.18.0' @@ -62,6 +63,31 @@ jobs: working-directory: apps/project-sites run: npm ci --legacy-peer-deps + - name: Install Angular frontend deps + working-directory: apps/project-sites/frontend + run: npm ci --legacy-peer-deps + + - name: Build Angular frontend (production config) + working-directory: apps/project-sites/frontend + run: npm run build:prod + + - name: Verify Angular bundle exists in dist + working-directory: apps/project-sites/frontend + run: | + test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; } + grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; } + echo "✓ Angular dist contains hashed bundle" + + - name: Deploy Angular bundle to R2 + working-directory: apps/project-sites/frontend + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + run: | + TARGET="${{ github.event_name == 'workflow_dispatch' && inputs.target || 'production' }}" + echo "Uploading Angular bundle to R2 for: $TARGET" + node scripts/deploy-r2.mjs "$TARGET" + - name: Deploy Worker (builds container image remotely) working-directory: apps/project-sites shell: bash @@ -124,6 +150,34 @@ jobs: echo "Listing recent deployments for env: $TARGET" npx wrangler deployments list --env "$TARGET" | head -20 + - name: Purge Cloudflare zone cache + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + ZONE_ID: '75a6f8d5e441cd7124552976ba894f83' + run: | + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \ + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' | jq . + + - name: Verify Angular bundle live (HARD GATE) + working-directory: apps/project-sites/frontend + run: | + TARGET="${{ github.event_name == 'workflow_dispatch' && inputs.target || 'production' }}" + if [ "$TARGET" = "production" ]; then + URL="https://projectsites.dev" + sleep 20 + node scripts/verify-deploy.mjs "$URL" + else + URL="https://sites-staging.megabyte.space" + sleep 20 + node scripts/verify-deploy.mjs "$URL" || \ + { echo "::warning::Staging verify failed — likely CF Bot Fight 403 from GHA IP. Manual verify required."; exit 0; } + fi + - name: Smoke test — homepage (best effort, tolerates CF Bot Fight 403) env: REAL_UA: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' @@ -134,8 +188,7 @@ jobs: else URL="https://sites-staging.megabyte.space" fi - echo "Probing: $URL (sleep 20s for edge propagation)" - sleep 20 + echo "Probing: $URL" CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 15 \ -A "$REAL_UA" \ -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \ diff --git a/.github/workflows/project-sites.yaml b/.github/workflows/project-sites.yaml index f3b287f0bb..89a6a7f575 100644 --- a/.github/workflows/project-sites.yaml +++ b/.github/workflows/project-sites.yaml @@ -5,12 +5,14 @@ on: branches: [main, staging] paths: - 'apps/project-sites/**' + - 'apps/project-sites/frontend/**' - 'packages/shared/**' - 'supabase/migrations/**' pull_request: branches: [main] paths: - 'apps/project-sites/**' + - 'apps/project-sites/frontend/**' - 'packages/shared/**' - 'supabase/migrations/**' workflow_dispatch: @@ -142,6 +144,28 @@ jobs: working-directory: apps/project-sites run: npm ci --legacy-peer-deps + - name: Install Angular frontend deps + working-directory: apps/project-sites/frontend + run: npm ci --legacy-peer-deps + + - name: Build Angular frontend (production config) + working-directory: apps/project-sites/frontend + run: npm run build:prod + + - name: Verify Angular bundle exists in dist + working-directory: apps/project-sites/frontend + run: | + test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; } + grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; } + echo "✓ Angular dist contains hashed bundle" + + - name: Deploy Angular bundle to R2 (staging) + working-directory: apps/project-sites/frontend + run: node scripts/deploy-r2.mjs staging + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + - name: Deploy Worker to staging id: deploy working-directory: apps/project-sites @@ -152,6 +176,26 @@ jobs: CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + - name: Purge Cloudflare zone cache (staging) + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + ZONE_ID: '75a6f8d5e441cd7124552976ba894f83' + run: | + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \ + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' | jq . + + - name: Verify Angular bundle live (staging — hard gate) + working-directory: apps/project-sites/frontend + run: | + sleep 10 + node scripts/verify-deploy.mjs https://sites-staging.megabyte.space || \ + { echo "::warning::Staging verify failed — likely CF Bot Fight 403 from GHA IP. Manual verify required."; exit 0; } + # ─── Stage 4: Playwright E2E on Staging (3 shards) ───────── e2e-staging: name: 'E2E Staging ${{ matrix.shard }}/3' @@ -222,6 +266,28 @@ jobs: working-directory: apps/project-sites run: npm ci --legacy-peer-deps + - name: Install Angular frontend deps + working-directory: apps/project-sites/frontend + run: npm ci --legacy-peer-deps + + - name: Build Angular frontend (production config) + working-directory: apps/project-sites/frontend + run: npm run build:prod + + - name: Verify Angular bundle exists in dist + working-directory: apps/project-sites/frontend + run: | + test -f dist/project-sites-frontend/browser/index.html || { echo "::error::Angular dist missing"; exit 1; } + grep -qE 'main-[A-Za-z0-9]+\.js' dist/project-sites-frontend/browser/index.html || { echo "::error::Hashed main-*.js missing from dist index.html"; exit 1; } + echo "✓ Angular dist contains hashed bundle" + + - name: Deploy Angular bundle to R2 (production) + working-directory: apps/project-sites/frontend + run: node scripts/deploy-r2.mjs production + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + - name: Deploy Worker to production working-directory: apps/project-sites run: npx wrangler deploy --env production @@ -229,6 +295,25 @@ jobs: CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + - name: Purge Cloudflare zone cache (production) + env: + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + ZONE_ID: '75a6f8d5e441cd7124552976ba894f83' + run: | + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \ + -H "X-Auth-Email: $CLOUDFLARE_EMAIL" \ + -H "X-Auth-Key: $CLOUDFLARE_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}' | jq . + + - name: Verify Angular bundle live (production — HARD GATE) + working-directory: apps/project-sites/frontend + run: | + sleep 15 + node scripts/verify-deploy.mjs https://projectsites.dev + # ─── Stage 6: Post-deploy E2E on Production (3 shards) ────── e2e-production: name: 'E2E Production ${{ matrix.shard }}/3' diff --git a/apps/project-sites/frontend/e2e/build-edit-snapshot-cycle.spec.ts b/apps/project-sites/frontend/e2e/build-edit-snapshot-cycle.spec.ts index 6407c19c32..4e6d13d8e2 100644 --- a/apps/project-sites/frontend/e2e/build-edit-snapshot-cycle.spec.ts +++ b/apps/project-sites/frontend/e2e/build-edit-snapshot-cycle.spec.ts @@ -16,7 +16,6 @@ import type { Page } from '@playwright/test'; /** Dismiss overlays that block interactions */ async function dismissOverlays(page: Page): Promise { await page.evaluate(() => { - localStorage.setItem('ps_onboarding', 'dismissed'); localStorage.setItem('ps_feedback_dismissed', 'true'); }); } diff --git a/apps/project-sites/frontend/e2e/fixtures.ts b/apps/project-sites/frontend/e2e/fixtures.ts index 66ee5a948c..cff6950adf 100644 --- a/apps/project-sites/frontend/e2e/fixtures.ts +++ b/apps/project-sites/frontend/e2e/fixtures.ts @@ -11,13 +11,12 @@ export const test = base.extend<{ authedPage: Page }>({ await page.goto('/'); await page.waitForLoadState('domcontentloaded'); - // Set session in localStorage + dismiss onboarding overlay + // Set session in localStorage await page.evaluate(() => { localStorage.setItem('ps_session', JSON.stringify({ token: 'mock-token-123', identifier: 'test@example.com', })); - localStorage.setItem('ps_onboarding', 'dismissed'); localStorage.setItem('ps_feedback_dismissed', 'true'); }); diff --git a/apps/project-sites/frontend/e2e/full-feature-coverage.spec.ts b/apps/project-sites/frontend/e2e/full-feature-coverage.spec.ts index 1fc9654226..e12deadb83 100644 --- a/apps/project-sites/frontend/e2e/full-feature-coverage.spec.ts +++ b/apps/project-sites/frontend/e2e/full-feature-coverage.spec.ts @@ -31,11 +31,11 @@ function filterNoise(errors: string[]): string[] { } /** - * Dismiss the onboarding overlay so it doesn't intercept clicks. - * Call this after page.goto('/') and waitForLoadState. + * No-op kept for backwards-compatibility with existing test calls. + * The onboarding overlay feature has been removed from the application. */ -async function dismissOnboarding(page: Page): Promise { - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); +async function dismissOnboarding(_page: Page): Promise { + // intentionally empty } // ═══════════════════════════════════════════════════════════ @@ -247,12 +247,8 @@ test.describe('Authentication Flow', () => { }); test('11 - Magic link form validates email format before submission', async ({ page }) => { - // Dismiss onboarding first to avoid pointer interception await page.goto('/signin'); await page.waitForLoadState('networkidle'); - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); - await page.reload(); - await page.waitForLoadState('networkidle'); // Click Continue with Email to show form const emailBtn = page.locator('button').filter({ hasText: /Continue with Email/i }); @@ -720,9 +716,6 @@ test.describe('Interactive Features', () => { await page.goto('/blog'); await page.waitForLoadState('networkidle'); - // Dismiss onboarding to avoid interference - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); - // Make sure no input is focused (click on body first) await page.locator('body').click({ position: { x: 10, y: 10 } }); await page.waitForTimeout(200); @@ -744,36 +737,28 @@ test.describe('Interactive Features', () => { await expect(overlay).toContainText('Escape'); }); - test('35 - Onboarding checklist appears for first-time visitors and can be dismissed', async ({ page }) => { + test('35 - Legacy onboarding keys are cleared on app bootstrap', async ({ page }) => { + // Seed stale onboarding state from a prior app version await page.goto('/'); await page.waitForLoadState('networkidle'); + await page.evaluate(() => { + localStorage.setItem('ps_onboarding', 'pending'); + localStorage.setItem('ps_onboarding_seen', 'false'); + }); - // Clear any existing onboarding state to simulate first visit - await page.evaluate(() => localStorage.removeItem('ps_onboarding')); + // Reload — AppComponent.cleanupLegacyOnboardingKeys() should wipe them await page.reload(); await page.waitForLoadState('networkidle'); - // Onboarding shows after 1500ms delay - const onboardingCard = page.locator('.onboarding-card'); - await expect(onboardingCard).toBeVisible({ timeout: 5000 }); - - // Should show welcome heading - await expect(onboardingCard).toContainText('Welcome to Project Sites'); - - // Should have steps listed - const steps = onboardingCard.locator('.step'); - const stepCount = await steps.count(); - expect(stepCount).toBeGreaterThan(0); - - // Click dismiss button - const dismissBtn = onboardingCard.locator('.dismiss-btn'); - await dismissBtn.click(); - - // Should disappear - await expect(onboardingCard).not.toBeVisible({ timeout: 2000 }); + const legacy = await page.evaluate(() => ({ + ps_onboarding: localStorage.getItem('ps_onboarding'), + ps_onboarding_seen: localStorage.getItem('ps_onboarding_seen'), + })); + expect(legacy.ps_onboarding).toBeNull(); + expect(legacy.ps_onboarding_seen).toBeNull(); - // localStorage should store dismissed state - const stored = await page.evaluate(() => localStorage.getItem('ps_onboarding')); - expect(stored).toBe('dismissed'); + // No onboarding UI should render + const onboardingCard = page.locator('.onboarding-card, app-onboarding'); + await expect(onboardingCard).toHaveCount(0); }); }); diff --git a/apps/project-sites/frontend/e2e/full-user-simulation.spec.ts b/apps/project-sites/frontend/e2e/full-user-simulation.spec.ts index a709afd9eb..881624b7da 100644 --- a/apps/project-sites/frontend/e2e/full-user-simulation.spec.ts +++ b/apps/project-sites/frontend/e2e/full-user-simulation.spec.ts @@ -13,17 +13,9 @@ import { test, expect } from './fixtures'; import { test as base, expect as baseExpect } from '@playwright/test'; import type { Page } from '@playwright/test'; -/** Dismiss the onboarding overlay + feedback widget if visible. */ +/** Dismiss the feedback widget overlay if visible. */ async function dismissOverlays(page: Page): Promise { - // Dismiss onboarding (shows after 1.5s delay) - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); - // Also dismiss feedback widget if it has a storage key await page.evaluate(() => localStorage.setItem('ps_feedback_dismissed', 'true')); - // Click away any visible overlays just in case - const closeBtn = page.locator('.close-btn, button[aria-label="Close onboarding"]').first(); - if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { - await closeBtn.click(); - } } // ═══════════════════════════════════════════════════════════════════════ @@ -301,7 +293,6 @@ test.describe('B — Keyboard & UI Interactions', () => { await page.waitForLoadState('domcontentloaded'); await page.evaluate(() => { localStorage.setItem('ps_session', JSON.stringify({ token: 'mock-token-123', identifier: 'test@example.com' })); - localStorage.setItem('ps_onboarding', 'dismissed'); }); // Intercept auth/me to return 401 BEFORE reload @@ -590,9 +581,6 @@ base.describe('E — Unauthenticated Flows', () => { await page.goto('http://localhost:4300/'); await page.waitForLoadState('domcontentloaded'); - // Dismiss onboarding before it appears - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); - // Navigate to create page (not logged in) await page.goto('http://localhost:4300/create'); await page.waitForLoadState('networkidle'); diff --git a/apps/project-sites/frontend/e2e/ultimate-feature-chains.spec.ts b/apps/project-sites/frontend/e2e/ultimate-feature-chains.spec.ts index 2d0898c2fc..e43ddf33a6 100644 --- a/apps/project-sites/frontend/e2e/ultimate-feature-chains.spec.ts +++ b/apps/project-sites/frontend/e2e/ultimate-feature-chains.spec.ts @@ -19,10 +19,9 @@ import type { Page } from '@playwright/test'; // Shared helpers // ═══════════════════════════════════════════════════════════════ -/** Dismiss onboarding + feedback overlays */ +/** Dismiss feedback overlay */ async function dismissOverlays(page: Page): Promise { await page.evaluate(() => { - localStorage.setItem('ps_onboarding', 'dismissed'); localStorage.setItem('ps_feedback_dismissed', 'true'); }); } @@ -685,7 +684,6 @@ test('11. Auth: OAuth redirect, magic link, session guard, sign out', async ({ p // 5. Now auth is set — verify admin is accessible await page.evaluate(() => { - localStorage.setItem('ps_onboarding', 'dismissed'); localStorage.setItem('ps_feedback_dismissed', 'true'); }); await page.goto('/admin'); @@ -857,10 +855,10 @@ test('13. Content: blog list, blog post with ToC, changelog, legal pages', async // ═══════════════════════════════════════════════════════════════ // TEST 14: A11y + i18n + Progressive Enhancement -// command palette, shortcuts, onboarding, feedback, Easter egg, language +// command palette, shortcuts, feedback, Easter egg, language // ═══════════════════════════════════════════════════════════════ -test('14. A11y + i18n: language toggle, Cmd+K palette, shortcuts, onboarding, feedback', async ({ page }) => { +test('14. A11y + i18n: language toggle, Cmd+K palette, shortcuts, feedback', async ({ page }) => { const errors = trackConsoleErrors(page); // 1. Visit homepage — fresh user (no localStorage dismissals) @@ -868,29 +866,8 @@ test('14. A11y + i18n: language toggle, Cmd+K palette, shortcuts, onboarding, fe await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(2000); - // Onboarding overlay should appear for new users (after 1.5s delay) - const onboarding = page.locator('[class*="onboarding"], [class*="overlay"]').first(); - if (await onboarding.isVisible({ timeout: 3000 }).catch(() => false)) { - // Dismiss via close button, "I'll explore on my own", or "Skip" - const closeBtn = page.locator('[class*="onboarding"] button:has-text("explore on my own")').first(); - const xBtn = page.locator('[class*="onboarding"] button:has-text("×"), [class*="onboarding"] button[class*="close"]').first(); - if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) { - await closeBtn.click(); - } else if (await xBtn.isVisible({ timeout: 1000 }).catch(() => false)) { - await xBtn.click(); - } else { - // Force dismiss via localStorage + reload - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); - await page.waitForTimeout(500); - } - await page.waitForTimeout(300); - } - - // Dismiss remaining overlays + // Dismiss feedback overlay await page.evaluate(() => { - localStorage.setItem('ps_onboarding', 'dismissed'); localStorage.setItem('ps_feedback_dismissed', 'true'); }); await page.reload(); @@ -944,7 +921,6 @@ test('14. A11y + i18n: language toggle, Cmd+K palette, shortcuts, onboarding, fe await page.reload(); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1000); - await page.evaluate(() => localStorage.setItem('ps_onboarding', 'dismissed')); const feedbackTab = page.locator('[class*="feedback"], button:has-text("Feedback")').first(); if (await feedbackTab.isVisible({ timeout: 3000 }).catch(() => false)) { diff --git a/apps/project-sites/frontend/package.json b/apps/project-sites/frontend/package.json index 77ab9fb37d..1b25fc8516 100644 --- a/apps/project-sites/frontend/package.json +++ b/apps/project-sites/frontend/package.json @@ -8,8 +8,9 @@ "build:prod": "ng build --configuration production", "watch": "ng build --watch --configuration development", "test": "ng test", - "deploy:staging": "npm run build:prod && cd .. && node -e \"const{execSync:e}=require('child_process');const fs=require('fs');const p='frontend/dist/project-sites-frontend/browser';fs.readdirSync(p).forEach(f=>{const ct=f.endsWith('.html')?'text/html':f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':f.endsWith('.ico')?'image/x-icon':'application/octet-stream';e('npx wrangler r2 object put project-sites-staging/marketing/'+f+' --file '+p+'/'+f+' --content-type '+ct+' --remote',{stdio:'inherit'})})\"", - "deploy:production": "npm run build:prod && cd .. && node -e \"const{execSync:e}=require('child_process');const fs=require('fs');const p='frontend/dist/project-sites-frontend/browser';fs.readdirSync(p).forEach(f=>{const ct=f.endsWith('.html')?'text/html':f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':f.endsWith('.ico')?'image/x-icon':'application/octet-stream';e('npx wrangler r2 object put project-sites/marketing/'+f+' --file '+p+'/'+f+' --content-type '+ct+' --remote',{stdio:'inherit'})})\"" + "deploy:staging": "npm run build:prod && node scripts/deploy-r2.mjs staging", + "deploy:production": "npm run build:prod && node scripts/deploy-r2.mjs production", + "verify:production": "node scripts/verify-deploy.mjs https://projectsites.dev" }, "private": true, "dependencies": { diff --git a/apps/project-sites/frontend/public/sw.js b/apps/project-sites/frontend/public/sw.js index a8ff46eea5..e16ab01f93 100644 --- a/apps/project-sites/frontend/public/sw.js +++ b/apps/project-sites/frontend/public/sw.js @@ -1,4 +1,4 @@ -var CACHE_NAME = 'project-sites-v3'; +var CACHE_NAME = 'project-sites-v6'; var API_CACHE_NAME = 'project-sites-api-v1'; var ASSETS_TO_CACHE = [ '/', diff --git a/apps/project-sites/frontend/scripts/verify-deploy.mjs b/apps/project-sites/frontend/scripts/verify-deploy.mjs new file mode 100644 index 0000000000..e282aaf58a --- /dev/null +++ b/apps/project-sites/frontend/scripts/verify-deploy.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Hard-gate verifier: confirms Angular bundle is live at the given URL. +// Fails non-zero if `main-.js` is missing from the HTML, which means +// R2 still holds the legacy static SPA instead of the Angular shell. +// +// Usage: node scripts/verify-deploy.mjs https://projectsites.dev + +import { exit } from 'node:process'; + +const url = process.argv[2]; +if (!url) { + console.error('usage: verify-deploy.mjs '); + exit(2); +} + +const UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +const probe = async (path) => { + const target = new URL(path, url).toString(); + const res = await fetch(target, { + headers: { + 'User-Agent': UA, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + }, + redirect: 'follow', + }); + const body = await res.text(); + return { status: res.status, body, target }; +}; + +const checks = []; + +try { + const root = await probe('/'); + const hasMain = /main-[A-Za-z0-9]+\.js/.test(root.body); + const hasPolyfills = /polyfills-[A-Za-z0-9]+\.js/.test(root.body); + const hasStyles = /styles-[A-Za-z0-9]+\.css/.test(root.body); + const hasAppRoot = / !c.ok); +for (const c of checks) { + const tag = c.ok ? 'PASS' : 'FAIL'; + console.warn(`[${tag}] ${c.name} ${c.target} -> ${c.status}`); + for (const [k, v] of Object.entries(c)) { + if (['name', 'target', 'status', 'ok'].includes(k)) continue; + console.warn(` ${k}: ${v}`); + } +} + +if (failed.length > 0) { + console.error(`\nverify-deploy: ${failed.length} check(s) failed — Angular bundle not live.`); + exit(1); +} + +console.warn('\nverify-deploy: all checks passed — Angular bundle confirmed live.'); diff --git a/apps/project-sites/frontend/src/app/animations/count-up.directive.ts b/apps/project-sites/frontend/src/app/animations/count-up.directive.ts new file mode 100644 index 0000000000..6f8128590d --- /dev/null +++ b/apps/project-sites/frontend/src/app/animations/count-up.directive.ts @@ -0,0 +1,97 @@ +import { + Directive, + ElementRef, + Input, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; + +/** + * Animates the host element's text content from `psCountFrom` to `psCountTo` using rAF + * when it enters the viewport. Locale-formats the number and supports an optional suffix + * (e.g. `+`, `%`, `K`). Snaps to the final value under `prefers-reduced-motion: reduce`. + */ +@Directive({ + selector: '[psCountUp]', + standalone: true, +}) +export class CountUpDirective implements OnInit, OnDestroy { + private readonly host = inject>(ElementRef); + + /** Target value (the final number rendered). */ + @Input({ required: true }) psCountUp!: number; + @Input() psCountFrom = 0; + @Input() psCountDuration = 1400; + @Input() psCountDecimals = 0; + @Input() psCountPrefix = ''; + @Input() psCountSuffix = ''; + @Input() psCountLocale = 'en-US'; + + private observer?: IntersectionObserver; + private rafId?: number; + private started = false; + + ngOnInit(): void { + const el = this.host.nativeElement; + this.render(this.psCountFrom); + + if (typeof window === 'undefined') { + this.render(this.psCountUp); + return; + } + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reducedMotion || typeof IntersectionObserver === 'undefined') { + this.render(this.psCountUp); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !this.started) { + this.started = true; + this.observer?.disconnect(); + this.start(); + } + } + }, + { threshold: 0.3 } + ); + this.observer.observe(el); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + if (this.rafId != null) cancelAnimationFrame(this.rafId); + } + + private start(): void { + const from = this.psCountFrom; + const to = this.psCountUp; + const duration = Math.max(120, this.psCountDuration); + const startTime = performance.now(); + + const tick = (now: number) => { + const t = Math.min(1, (now - startTime) / duration); + const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic + const value = from + (to - from) * eased; + this.render(value); + if (t < 1) { + this.rafId = requestAnimationFrame(tick); + } else { + this.render(to); + } + }; + this.rafId = requestAnimationFrame(tick); + } + + private render(value: number): void { + const formatted = value.toLocaleString(this.psCountLocale, { + minimumFractionDigits: this.psCountDecimals, + maximumFractionDigits: this.psCountDecimals, + }); + this.host.nativeElement.textContent = `${this.psCountPrefix}${formatted}${this.psCountSuffix}`; + } +} diff --git a/apps/project-sites/frontend/src/app/animations/motion.ts b/apps/project-sites/frontend/src/app/animations/motion.ts new file mode 100644 index 0000000000..1887700f74 --- /dev/null +++ b/apps/project-sites/frontend/src/app/animations/motion.ts @@ -0,0 +1,187 @@ +import { + animate, + animateChild, + group, + keyframes, + query, + stagger, + state, + style, + transition, + trigger, + type AnimationTriggerMetadata, +} from '@angular/animations'; + +const DUR_XS = '120ms'; +const DUR_SM = '180ms'; +const DUR_MD = '260ms'; +const DUR_LG = '420ms'; + +const EASE_DECEL = 'cubic-bezier(0, 0, 0.2, 1)'; +const EASE_ACCEL = 'cubic-bezier(0.4, 0, 1, 1)'; +const EASE_SPRING_SOFT = 'cubic-bezier(0.16, 1, 0.3, 1)'; + +/** + * Cross-fade with a small upward rise. Default content entrance. + * Use on the host element of a route component or top-level container. + */ +export const fadeRise: AnimationTriggerMetadata = trigger('fadeRise', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translate3d(0, 12px, 0)' }), + animate(`${DUR_LG} ${EASE_SPRING_SOFT}`, style({ opacity: 1, transform: 'translate3d(0, 0, 0)' })), + ]), + transition(':leave', [animate(`${DUR_SM} ${EASE_ACCEL}`, style({ opacity: 0 }))]), +]); + +/** + * Center-anchored scale + fade. Modals, command palettes, popovers. + */ +export const scaleFade: AnimationTriggerMetadata = trigger('scaleFade', [ + transition(':enter', [ + style({ opacity: 0, transform: 'scale(0.94)' }), + animate(`${DUR_MD} ${EASE_SPRING_SOFT}`, style({ opacity: 1, transform: 'scale(1)' })), + ]), + transition(':leave', [ + animate(`${DUR_XS} ${EASE_ACCEL}`, style({ opacity: 0, transform: 'scale(0.96)' })), + ]), +]); + +/** + * Right-edge drawer. Used by side panels. + */ +export const drawerSlide: AnimationTriggerMetadata = trigger('drawerSlide', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translate3d(100%, 0, 0)' }), + animate(`${DUR_LG} ${EASE_SPRING_SOFT}`, style({ opacity: 1, transform: 'translate3d(0, 0, 0)' })), + ]), + transition(':leave', [ + animate(`${DUR_MD} ${EASE_ACCEL}`, style({ opacity: 0, transform: 'translate3d(100%, 0, 0)' })), + ]), +]); + +/** + * Bottom-edge toast slide-up with scale settle. + */ +export const toastSlide: AnimationTriggerMetadata = trigger('toastSlide', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translate3d(0, 16px, 0) scale(0.96)' }), + animate( + `${DUR_LG} ${EASE_SPRING_SOFT}`, + style({ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)' }) + ), + ]), + transition(':leave', [ + animate( + `${DUR_SM} ${EASE_ACCEL}`, + style({ opacity: 0, transform: 'translate3d(0, 8px, 0) scale(0.98)' }) + ), + ]), +]); + +/** + * Dialog/modal: scale + tiny rise; pairs with a backdrop fade. + */ +export const dialogScaleFade: AnimationTriggerMetadata = trigger('dialogScaleFade', [ + transition(':enter', [ + style({ opacity: 0, transform: 'scale(0.94) translate3d(0, 8px, 0)' }), + animate( + `${DUR_MD} ${EASE_SPRING_SOFT}`, + style({ opacity: 1, transform: 'scale(1) translate3d(0, 0, 0)' }) + ), + ]), + transition(':leave', [ + animate( + `${DUR_XS} ${EASE_ACCEL}`, + style({ opacity: 0, transform: 'scale(0.96) translate3d(0, 4px, 0)' }) + ), + ]), +]); + +/** + * List stagger. Apply on a container; queries `:enter` children and rises them in sequence. + * Pair the items with `*ngFor`/`@for` so Angular triggers `:enter` per item. + */ +export const listStagger: AnimationTriggerMetadata = trigger('listStagger', [ + transition('* => *', [ + query( + ':enter', + [ + style({ opacity: 0, transform: 'translate3d(0, 14px, 0)' }), + stagger(60, [ + animate( + `${DUR_LG} ${EASE_SPRING_SOFT}`, + style({ opacity: 1, transform: 'translate3d(0, 0, 0)' }) + ), + ]), + ], + { optional: true } + ), + ]), +]); + +/** + * Tab/accordion content fade — no height animation (avoids layout thrash). + */ +export const contentFade: AnimationTriggerMetadata = trigger('contentFade', [ + transition(':enter', [ + style({ opacity: 0 }), + animate(`${DUR_MD} ${EASE_DECEL}`, style({ opacity: 1 })), + ]), + transition(':leave', [animate(`${DUR_XS} ${EASE_ACCEL}`, style({ opacity: 0 }))]), +]); + +/** + * Router-outlet animation — wraps inbound/outbound route components in a cross-fade + * while letting their internal animations run via `animateChild()`. + */ +export const routeAnimations: AnimationTriggerMetadata = trigger('routeAnimations', [ + transition('* <=> *', [ + style({ position: 'relative' }), + query(':enter, :leave', [style({ position: 'absolute', top: 0, left: 0, width: '100%' })], { + optional: true, + }), + query(':enter', [style({ opacity: 0, transform: 'translate3d(0, 8px, 0)' })], { + optional: true, + }), + group([ + query(':leave', [animate(`${DUR_XS} ${EASE_ACCEL}`, style({ opacity: 0 }))], { + optional: true, + }), + query( + ':enter', + [ + animate( + `${DUR_MD} ${EASE_SPRING_SOFT}`, + style({ opacity: 1, transform: 'translate3d(0, 0, 0)' }) + ), + ], + { optional: true } + ), + query('@*', animateChild(), { optional: true }), + ]), + ]), +]); + +/** + * Pressed/loading/disabled button state machine. + * Bind to `[@buttonState]` with a string state. + */ +export const buttonState: AnimationTriggerMetadata = trigger('buttonState', [ + state('idle', style({ transform: 'scale(1)' })), + state('press', style({ transform: 'scale(0.97)' })), + state('loading', style({ transform: 'scale(1)' })), + state('success', style({ transform: 'scale(1.03)' })), + state('disabled', style({ transform: 'scale(1)', opacity: 0.55 })), + transition('* <=> press', animate(`${DUR_XS} ${EASE_DECEL}`)), + transition('* => success', [ + animate( + `${DUR_MD} ${EASE_SPRING_SOFT}`, + keyframes([ + style({ transform: 'scale(1)', offset: 0 }), + style({ transform: 'scale(1.06)', offset: 0.5 }), + style({ transform: 'scale(1.02)', offset: 1 }), + ]) + ), + ]), + transition('* <=> *', animate(`${DUR_SM} ${EASE_DECEL}`)), +]); diff --git a/apps/project-sites/frontend/src/app/animations/reveal-on-scroll.directive.ts b/apps/project-sites/frontend/src/app/animations/reveal-on-scroll.directive.ts new file mode 100644 index 0000000000..e7beb44100 --- /dev/null +++ b/apps/project-sites/frontend/src/app/animations/reveal-on-scroll.directive.ts @@ -0,0 +1,75 @@ +import { + Directive, + ElementRef, + Input, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; + +/** + * Toggles the `is-visible` class on the host element when it enters the viewport. + * Pairs with the `.ps-reveal` utility in `styles.scss`. Respects `prefers-reduced-motion` + * by adding the class immediately so content is never hidden from users who opted out. + */ +@Directive({ + selector: '[psReveal]', + standalone: true, + host: { + class: 'ps-reveal', + }, +}) +export class RevealOnScrollDirective implements OnInit, OnDestroy { + private readonly host = inject>(ElementRef); + + /** Fraction of the element that must be visible before triggering. 0–1. */ + @Input() psRevealThreshold = 0.15; + + /** Optional pixel offset (rootMargin) before triggering. Negative values delay. */ + @Input() psRevealMargin = '0px 0px -8% 0px'; + + /** Only fire once. Set to false for repeating reveals. */ + @Input() psRevealOnce = true; + + private observer?: IntersectionObserver; + + ngOnInit(): void { + const el = this.host.nativeElement; + + if (typeof window === 'undefined') { + el.classList.add('is-visible'); + return; + } + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reducedMotion || typeof IntersectionObserver === 'undefined') { + el.classList.add('is-visible'); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + if (this.psRevealOnce) { + this.observer?.unobserve(entry.target); + } + } else if (!this.psRevealOnce) { + entry.target.classList.remove('is-visible'); + } + } + }, + { + threshold: this.psRevealThreshold, + rootMargin: this.psRevealMargin, + } + ); + + this.observer.observe(el); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + } +} diff --git a/apps/project-sites/frontend/src/app/animations/ripple.directive.ts b/apps/project-sites/frontend/src/app/animations/ripple.directive.ts new file mode 100644 index 0000000000..2afc871b4f --- /dev/null +++ b/apps/project-sites/frontend/src/app/animations/ripple.directive.ts @@ -0,0 +1,63 @@ +import { + Directive, + ElementRef, + HostListener, + Input, + OnInit, + inject, +} from '@angular/core'; + +/** + * Material-style click ripple emitted at the pointer position. Skipped under + * `prefers-reduced-motion: reduce`. The host element receives `position: relative` + * and `overflow: hidden` so the ripple is contained. + */ +@Directive({ + selector: '[psRipple]', + standalone: true, +}) +export class RippleDirective implements OnInit { + private readonly host = inject>(ElementRef); + + @Input() psRippleColor = 'rgba(0, 229, 255, 0.35)'; + @Input() psRippleDuration = 520; + + ngOnInit(): void { + const el = this.host.nativeElement; + const computed = getComputedStyle(el); + if (computed.position === 'static') { + el.style.position = 'relative'; + } + el.style.overflow = el.style.overflow || 'hidden'; + } + + @HostListener('pointerdown', ['$event']) + onPointerDown(event: PointerEvent): void { + if (typeof window === 'undefined') return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const el = this.host.nativeElement; + const rect = el.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height) * 1.6; + + const ripple = document.createElement('span'); + ripple.className = 'ps-ripple-ink'; + ripple.style.cssText = ` + position: absolute; + pointer-events: none; + border-radius: 50%; + width: ${size}px; + height: ${size}px; + left: ${event.clientX - rect.left - size / 2}px; + top: ${event.clientY - rect.top - size / 2}px; + background: ${this.psRippleColor}; + transform: scale(0); + opacity: 0.6; + animation: psRippleInk ${this.psRippleDuration}ms cubic-bezier(0.2, 0, 0, 1) forwards; + will-change: transform, opacity; + z-index: 0; + `; + el.appendChild(ripple); + ripple.addEventListener('animationend', () => ripple.remove(), { once: true }); + } +} diff --git a/apps/project-sites/frontend/src/app/app.component.ts b/apps/project-sites/frontend/src/app/app.component.ts index 423b29b775..169fa18270 100644 --- a/apps/project-sites/frontend/src/app/app.component.ts +++ b/apps/project-sites/frontend/src/app/app.component.ts @@ -7,7 +7,6 @@ import { BgOrbsComponent } from './components/bg-orbs/bg-orbs.component'; import { EasterEggsComponent } from './components/easter-eggs/easter-eggs.component'; import { CommandPaletteComponent } from './components/command-palette/command-palette.component'; import { ShortcutsOverlayComponent } from './components/shortcuts-overlay/shortcuts-overlay.component'; -import { OnboardingComponent } from './components/onboarding/onboarding.component'; import { FeedbackWidgetComponent } from './components/feedback-widget/feedback-widget.component'; import { AuthService } from './services/auth.service'; import { ApiService } from './services/api.service'; @@ -16,7 +15,7 @@ import { MetaService } from './services/meta.service'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, HeaderComponent, ToastComponent, BgOrbsComponent, EasterEggsComponent, CommandPaletteComponent, ShortcutsOverlayComponent, OnboardingComponent, FeedbackWidgetComponent], + imports: [RouterOutlet, HeaderComponent, ToastComponent, BgOrbsComponent, EasterEggsComponent, CommandPaletteComponent, ShortcutsOverlayComponent, FeedbackWidgetComponent], template: ` Skip to main content @if (showHeader()) { } @@ -35,7 +34,6 @@ import { MetaService } from './services/meta.service';
- `, styles: [` @@ -102,12 +100,22 @@ export class AppComponent implements OnInit, OnDestroy { ngOnInit(): void { this.meta.init(); + this.cleanupLegacyOnboardingKeys(); this.handleAuthCallback(); this.restoreSession(); this.trackRoute(); this.initCursorFollower(); } + private cleanupLegacyOnboardingKeys(): void { + try { + localStorage.removeItem('ps_onboarding'); + localStorage.removeItem('ps_onboarding_seen'); + } catch { + // ignore — private mode / quota + } + } + private isHeaderlessRoute(url: string): boolean { const path = url.split('?')[0]; // Homepage has its own nav; admin/billing/editor have their own chrome diff --git a/apps/project-sites/frontend/src/app/app.config.ts b/apps/project-sites/frontend/src/app/app.config.ts index 28b0c6c07a..17e0965a21 100644 --- a/apps/project-sites/frontend/src/app/app.config.ts +++ b/apps/project-sites/frontend/src/app/app.config.ts @@ -1,5 +1,5 @@ import { type ApplicationConfig, APP_INITIALIZER, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withViewTransitions } from '@angular/router'; import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { provideAnimations } from '@angular/platform-browser/animations'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -36,7 +36,7 @@ function initTranslations(translate: TranslateService) { export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), + provideRouter(routes, withViewTransitions({ skipInitialTransition: true })), provideHttpClient(withFetch(), withInterceptors([retryInterceptor, loadingInterceptor])), provideAnimations(), { provide: ErrorHandler, useClass: GlobalErrorHandler }, diff --git a/apps/project-sites/frontend/src/app/components/command-palette/command-palette.component.ts b/apps/project-sites/frontend/src/app/components/command-palette/command-palette.component.ts index 9659a403b0..90d24116fb 100644 --- a/apps/project-sites/frontend/src/app/components/command-palette/command-palette.component.ts +++ b/apps/project-sites/frontend/src/app/components/command-palette/command-palette.component.ts @@ -1,5 +1,6 @@ import { Component, inject, signal, computed, ElementRef, ViewChild, type AfterViewInit, type OnDestroy, EventEmitter, Output } from '@angular/core'; import { Router } from '@angular/router'; +import { scaleFade, listStagger } from '../../animations/motion'; interface PaletteCommand { id: string; @@ -27,6 +28,7 @@ const COMMANDS: PaletteCommand[] = [ @Component({ selector: 'app-command-palette', standalone: true, + animations: [scaleFade, listStagger], template: `
-
+
@@ -55,7 +57,7 @@ const COMMANDS: PaletteCommand[] = [ esc
-
    +
      @for (cmd of filtered(); track cmd.id; let i = $index) {
    • `, styles: [` - @keyframes fadeInScale { - from { opacity: 0; transform: translateY(-12px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } - } @keyframes fadeInBg { from { opacity: 0; } to { opacity: 1; } @@ -114,7 +112,9 @@ const COMMANDS: PaletteCommand[] = [ 0 0 0 1px rgba(0, 229, 255, 0.06), 0 0 60px rgba(0, 229, 255, 0.04); overflow: hidden; - animation: fadeInScale 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + } + @media (prefers-reduced-motion: reduce) { + .palette-backdrop { animation: none; } } /* Input area */ diff --git a/apps/project-sites/frontend/src/app/components/onboarding/onboarding.component.ts b/apps/project-sites/frontend/src/app/components/onboarding/onboarding.component.ts deleted file mode 100644 index 0577f1aa9c..0000000000 --- a/apps/project-sites/frontend/src/app/components/onboarding/onboarding.component.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Component, type OnInit, type OnDestroy, signal, inject } from '@angular/core'; -import { Router, NavigationEnd } from '@angular/router'; -import { Subscription, filter } from 'rxjs'; -import { AuthService } from '../../services/auth.service'; - -interface OnboardingStep { - id: string; - title: string; - description: string; - icon: string; - route?: string; - completed: boolean; -} - -@Component({ - selector: 'app-onboarding', - standalone: true, - imports: [], - template: ` - @if (visible()) { -
      -
      -
      -

      Welcome to Project Sites

      -

      Get your AI-generated website live in 5 minutes

      - -
      - -
      -
      -
      - {{ completedCount() }}/{{ steps().length }} complete - -
      - @for (step of steps(); track step.id) { -
      -
      - @if (step.completed) { - - - - } @else { -
      - } -
      -
      - {{ step.title }} - {{ step.description }} -
      - - - -
      - } -
      - -
      - -
      -
      -
      - } - `, - styles: [` - .onboarding-overlay { - position: fixed; inset: 0; z-index: 10000; - background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); - display: flex; align-items: center; justify-content: center; - animation: fadeIn 0.2s ease; - } - .onboarding-card { - background: #0d0d1a; border: 1px solid rgba(0,229,255,0.15); - border-radius: 16px; padding: 32px; max-width: 480px; width: 90%; - animation: fadeInScale 0.3s ease; - position: relative; - } - .onboarding-header { margin-bottom: 20px; } - .onboarding-header h2 { - font-family: 'Sora', sans-serif; font-size: 22px; font-weight: 700; - color: #f0f0f8; margin: 0 0 4px; - } - .onboarding-header p { - font-size: 14px; color: #94a3b8; margin: 0; - } - .close-btn { - position: absolute; top: 16px; right: 16px; background: none; - border: none; color: #94a3b8; cursor: pointer; padding: 4px; - } - .close-btn:hover { color: #f0f0f8; } - .progress-bar { - height: 4px; background: #1e1e3a; border-radius: 2px; - overflow: hidden; margin-bottom: 6px; - } - .progress-fill { - height: 100%; background: linear-gradient(90deg, #00E5FF, #50AAE3); - border-radius: 2px; transition: width 0.4s ease; - } - .progress-label { - font-size: 12px; color: #64748b; margin-bottom: 16px; display: block; - } - .steps { display: flex; flex-direction: column; gap: 4px; } - .step { - display: flex; align-items: center; gap: 12px; - padding: 12px; border-radius: 10px; cursor: pointer; - transition: background 0.15s ease; - } - .step:hover { background: rgba(0,229,255,0.04); } - .step.completed { opacity: 0.6; } - .step-check { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } - .step-circle { - width: 18px; height: 18px; border-radius: 50%; - border: 2px solid #3e3e5a; - } - .step-content { flex: 1; display: flex; flex-direction: column; } - .step-title { font-size: 14px; font-weight: 600; color: #f0f0f8; } - .step-desc { font-size: 12px; color: #94a3b8; } - .step-arrow { color: #3e3e5a; flex-shrink: 0; } - .onboarding-footer { margin-top: 20px; text-align: center; } - .dismiss-btn { - background: none; border: 1px solid #2e2e4a; color: #94a3b8; - padding: 8px 20px; border-radius: 8px; cursor: pointer; - font-size: 13px; transition: all 0.15s ease; - } - .dismiss-btn:hover { border-color: #00E5FF; color: #00E5FF; } - @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } - @keyframes fadeInScale { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } } - `], -}) -export class OnboardingComponent implements OnInit, OnDestroy { - private router = inject(Router); - private auth = inject(AuthService); - private readonly STORAGE_KEY = 'ps_onboarding'; - private readonly SEEN_KEY = 'ps_onboarding_seen'; - private routerSub?: Subscription; - private shownThisSession = false; - - visible = signal(false); - - steps = signal([ - { id: 'search', title: 'Search for your business', description: 'Find your business on Google Places', icon: 'search', route: '/search', completed: false }, - { id: 'signin', title: 'Sign in to your account', description: 'Create an account with email or Google', icon: 'login', route: '/signin', completed: false }, - { id: 'create', title: 'Create your site', description: 'Add details and let AI build it', icon: 'create', route: '/create', completed: false }, - { id: 'preview', title: 'Preview and publish', description: 'Review your generated website', icon: 'preview', route: '/admin', completed: false }, - { id: 'domain', title: 'Connect a custom domain', description: 'Point your own domain to your site', icon: 'domain', route: '/admin/settings', completed: false }, - ]); - - completedCount = signal(0); - progressPercent = signal(0); - - ngOnInit(): void { - const stored = localStorage.getItem(this.STORAGE_KEY); - if (stored && stored !== 'dismissed') { - try { - const completed: string[] = JSON.parse(stored); - const updated = this.steps().map((s) => ({ ...s, completed: completed.includes(s.id) })); - this.steps.set(updated); - this.updateProgress(); - } catch { - // ignore parse errors - } - } - - this.maybeShow(this.router.url); - this.routerSub = this.router.events - .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) - .subscribe((e) => this.maybeShow(e.urlAfterRedirects)); - } - - ngOnDestroy(): void { - this.routerSub?.unsubscribe(); - } - - /** Show the tour only on the first /admin visit after sign-in. */ - private maybeShow(url: string): void { - if (this.shownThisSession) return; - if (localStorage.getItem(this.SEEN_KEY) === '1') return; - if (!this.auth.isLoggedIn()) return; - const path = url.split('?')[0]; - if (!path.startsWith('/admin')) return; - this.shownThisSession = true; - setTimeout(() => this.visible.set(true), 800); - } - - dismiss(): void { - this.visible.set(false); - localStorage.setItem(this.SEEN_KEY, '1'); - localStorage.setItem(this.STORAGE_KEY, 'dismissed'); - } - - goToStep(step: OnboardingStep): void { - if (step.route) { - this.router.navigate([step.route]); - this.markComplete(step.id); - this.visible.set(false); - localStorage.setItem(this.SEEN_KEY, '1'); - } - } - - markComplete(stepId: string): void { - const updated = this.steps().map((s) => - s.id === stepId ? { ...s, completed: true } : s, - ); - this.steps.set(updated); - this.updateProgress(); - const completedIds = updated.filter((s) => s.completed).map((s) => s.id); - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(completedIds)); - } - - private updateProgress(): void { - const count = this.steps().filter((s) => s.completed).length; - this.completedCount.set(count); - this.progressPercent.set((count / this.steps().length) * 100); - } -} diff --git a/apps/project-sites/frontend/src/app/components/shortcuts-overlay/shortcuts-overlay.component.ts b/apps/project-sites/frontend/src/app/components/shortcuts-overlay/shortcuts-overlay.component.ts index e9491dc53b..cfe3fdfb89 100644 --- a/apps/project-sites/frontend/src/app/components/shortcuts-overlay/shortcuts-overlay.component.ts +++ b/apps/project-sites/frontend/src/app/components/shortcuts-overlay/shortcuts-overlay.component.ts @@ -1,4 +1,5 @@ import { Component, EventEmitter, Output, HostListener } from '@angular/core'; +import { scaleFade } from '../../animations/motion'; interface ShortcutEntry { keys: string[]; @@ -16,6 +17,7 @@ const SHORTCUTS: ShortcutEntry[] = [ @Component({ selector: 'app-shortcuts-overlay', standalone: true, + animations: [scaleFade], template: `
      -
      +

      @@ -62,14 +64,13 @@ const SHORTCUTS: ShortcutEntry[] = [

      `, styles: [` - @keyframes fadeInScale { - from { opacity: 0; transform: translateY(-8px) scale(0.97); } - to { opacity: 1; transform: translateY(0) scale(1); } - } @keyframes fadeInBg { from { opacity: 0; } to { opacity: 1; } } + @media (prefers-reduced-motion: reduce) { + .shortcuts-backdrop { animation: none; } + } .shortcuts-backdrop { position: fixed; inset: 0; z-index: 9999; @@ -89,7 +90,6 @@ const SHORTCUTS: ShortcutEntry[] = [ 0 0 0 1px rgba(0, 229, 255, 0.06), 0 0 60px rgba(0, 229, 255, 0.04); overflow: hidden; - animation: fadeInScale 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); } .shortcuts-header { diff --git a/apps/project-sites/frontend/src/app/components/toast/toast.component.ts b/apps/project-sites/frontend/src/app/components/toast/toast.component.ts index c18a620059..86c07fde38 100644 --- a/apps/project-sites/frontend/src/app/components/toast/toast.component.ts +++ b/apps/project-sites/frontend/src/app/components/toast/toast.component.ts @@ -1,13 +1,16 @@ import { Component, inject } from '@angular/core'; import { ToastService } from '../../services/toast.service'; +import { toastSlide } from '../../animations/motion'; @Component({ selector: 'app-toast', standalone: true, + animations: [toastSlide], template: `
      @for (toast of toastService.toasts(); track toast.id) {
      @if (state.selectedSite(); as site) { @@ -75,8 +80,8 @@ import { ApiService, type LogEntry } from '../../../services/api.service';
      -
      -
      +
      - {{ domainCount() }} + Domains
      -
      -
      - {{ state.analytics()?.stats?.uniqueVisitors ?? 0 }} + Visitors
      -
      - 0 + Submissions
      -
      +
      @@ -173,7 +178,7 @@ import { ApiService, type LogEntry } from '../../../services/api.service';
      -
      Manage →
      -
      Manage →
      -
      Manage →
      -

      - +
      } @@ -84,7 +85,7 @@

      +
      @@ -109,7 +110,7 @@

      +
      @for (item of results(); track item.name + item.address) {