fix(demo): smoke-test fixes — card reset, toast error type, apply hint, text contrast
- JobCardStack: expose resetCard() to restore card after a blocked action - JobReviewView: call resetCard() when approve/reject returns false; prevents card going blank after demo guard blocks the action - useApi: add 'demo-blocked' to ApiError union; return truthy error from the 403 interceptor so store callers bail early (no optimistic UI update) - ApplyView: add HintChip to desktop split-pane layout (was mobile-only) - HintChip: fix text color — --app-primary-light is near-white in light theme, causing invisible text; switch to --color-text for cross-theme contrast - vite.config.ts: support VITE_API_TARGET env var for dev proxy override - migrations/006: add date_posted, hired_feedback columns and references_ table (columns existed in live DB but were missing from migration history) - DemoBanner: commit component and test (were untracked)
This commit is contained in:
parent
689703d065
commit
f8c78031a0
9 changed files with 155 additions and 6 deletions
22
migrations/006_missing_columns.sql
Normal file
22
migrations/006_missing_columns.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Migration 006: Add columns and tables present in the live DB but missing from migrations
|
||||||
|
-- These were added via direct ALTER TABLE after the v0.8.5 baseline was written.
|
||||||
|
|
||||||
|
-- date_posted: used for ghost-post shadow-score detection
|
||||||
|
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
|
||||||
|
|
||||||
|
-- hired_feedback: JSON blob saved when a job reaches the 'hired' outcome
|
||||||
|
ALTER TABLE jobs ADD COLUMN hired_feedback TEXT;
|
||||||
|
|
||||||
|
-- references_ table: contacts who can provide references for applications
|
||||||
|
CREATE TABLE IF NOT EXISTS references_ (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
relationship TEXT,
|
||||||
|
company TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
prep_email TEXT,
|
||||||
|
role TEXT
|
||||||
|
);
|
||||||
79
web/src/components/DemoBanner.vue
Normal file
79
web/src/components/DemoBanner.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div class="demo-banner" role="status" aria-live="polite">
|
||||||
|
<span class="demo-banner__label">👁 Demo mode — changes are not saved</span>
|
||||||
|
<div class="demo-banner__ctas">
|
||||||
|
<a
|
||||||
|
href="https://circuitforge.tech/peregrine"
|
||||||
|
class="demo-banner__cta demo-banner__cta--primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Get free key</a>
|
||||||
|
<a
|
||||||
|
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
|
||||||
|
class="demo-banner__cta demo-banner__cta--secondary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Self-host</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No props — DemoBanner is only rendered when config.isDemo is true (App.vue)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.demo-banner {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__ctas {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta--primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta--secondary {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.demo-banner__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -44,7 +44,7 @@ function dismiss(): void {
|
||||||
.hint-chip__message {
|
.hint-chip__message {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--app-primary-light, #68A8D8);
|
color: var(--color-text, #1a202c);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,23 @@ watch(() => props.job.id, () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ dismissApprove, dismissReject, dismissSkip })
|
/** Restore card to its neutral state — used when an action is blocked (e.g. demo guard). */
|
||||||
|
function resetCard() {
|
||||||
|
dx.value = 0
|
||||||
|
dy.value = 0
|
||||||
|
isExiting.value = false
|
||||||
|
isHeld.value = false
|
||||||
|
if (wrapperEl.value) {
|
||||||
|
wrapperEl.value.style.transition = 'none'
|
||||||
|
wrapperEl.value.style.transform = ''
|
||||||
|
wrapperEl.value.style.opacity = ''
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (wrapperEl.value) wrapperEl.value.style.transition = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ dismissApprove, dismissReject, dismissSkip, resetCard })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
22
web/src/components/__tests__/DemoBanner.test.ts
Normal file
22
web/src/components/__tests__/DemoBanner.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DemoBanner from '../DemoBanner.vue'
|
||||||
|
|
||||||
|
describe('DemoBanner', () => {
|
||||||
|
it('renders the demo label', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.text()).toContain('Demo mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a free key link', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.find('a.demo-banner__cta--primary').exists()).toBe(true)
|
||||||
|
expect(w.find('a.demo-banner__cta--primary').text()).toContain('free key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a self-host link', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.find('a.demo-banner__cta--secondary').exists()).toBe(true)
|
||||||
|
expect(w.find('a.demo-banner__cta--secondary').text()).toContain('Self-host')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,6 +3,7 @@ import { showToast } from './useToast'
|
||||||
export type ApiError =
|
export type ApiError =
|
||||||
| { kind: 'network'; message: string }
|
| { kind: 'network'; message: string }
|
||||||
| { kind: 'http'; status: number; detail: string }
|
| { kind: 'http'; status: number; detail: string }
|
||||||
|
| { kind: 'demo-blocked' }
|
||||||
|
|
||||||
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
||||||
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
@ -21,7 +22,9 @@ export async function useApiFetch<T>(
|
||||||
const body = JSON.parse(rawText) as { detail?: string }
|
const body = JSON.parse(rawText) as { detail?: string }
|
||||||
if (body.detail === 'demo-write-blocked') {
|
if (body.detail === 'demo-write-blocked') {
|
||||||
showToast('Demo mode — sign in to save changes')
|
showToast('Demo mode — sign in to save changes')
|
||||||
return { data: null, error: null }
|
// Return a truthy error so callers bail early (no optimistic UI update),
|
||||||
|
// but the toast is already shown so no additional error handling needed.
|
||||||
|
return { data: null, error: { kind: 'demo-blocked' as const } }
|
||||||
}
|
}
|
||||||
} catch { /* not JSON — fall through to normal error */ }
|
} catch { /* not JSON — fall through to normal error */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@
|
||||||
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
||||||
<!-- Left: narrow job list -->
|
<!-- Left: narrow job list -->
|
||||||
<div class="apply-split__list">
|
<div class="apply-split__list">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="apply"
|
||||||
|
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
|
||||||
|
/>
|
||||||
<div class="split-list__header">
|
<div class="split-list__header">
|
||||||
<h1 class="split-list__title">Apply</h1>
|
<h1 class="split-list__title">Apply</h1>
|
||||||
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
|
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,8 @@ function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
|
||||||
async function onApprove() {
|
async function onApprove() {
|
||||||
const job = store.currentJob
|
const job = store.currentJob
|
||||||
if (!job) return
|
if (!job) return
|
||||||
await store.approve(job)
|
const ok = await store.approve(job)
|
||||||
|
if (!ok) { stackRef.value?.resetCard(); return }
|
||||||
showUndoToast('approved')
|
showUndoToast('approved')
|
||||||
checkStoopSpeed()
|
checkStoopSpeed()
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +283,8 @@ async function onApprove() {
|
||||||
async function onReject() {
|
async function onReject() {
|
||||||
const job = store.currentJob
|
const job = store.currentJob
|
||||||
if (!job) return
|
if (!job) return
|
||||||
await store.reject(job)
|
const ok = await store.reject(job)
|
||||||
|
if (!ok) { stackRef.value?.resetCard(); return }
|
||||||
showUndoToast('rejected')
|
showUndoToast('rejected')
|
||||||
checkStoopSpeed()
|
checkStoopSpeed()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8601',
|
target: process.env.VITE_API_TARGET || 'http://localhost:8601',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue