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 {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: var(--app-primary-light, #68A8D8);
|
||||
color: var(--color-text, #1a202c);
|
||||
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>
|
||||
|
||||
<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 =
|
||||
| { kind: 'network'; message: string }
|
||||
| { kind: 'http'; status: number; detail: string }
|
||||
| { kind: 'demo-blocked' }
|
||||
|
||||
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
||||
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 }
|
||||
if (body.detail === 'demo-write-blocked') {
|
||||
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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
||||
<!-- Left: narrow job 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">
|
||||
<h1 class="split-list__title">Apply</h1>
|
||||
<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() {
|
||||
const job = store.currentJob
|
||||
if (!job) return
|
||||
await store.approve(job)
|
||||
const ok = await store.approve(job)
|
||||
if (!ok) { stackRef.value?.resetCard(); return }
|
||||
showUndoToast('approved')
|
||||
checkStoopSpeed()
|
||||
}
|
||||
|
|
@ -282,7 +283,8 @@ async function onApprove() {
|
|||
async function onReject() {
|
||||
const job = store.currentJob
|
||||
if (!job) return
|
||||
await store.reject(job)
|
||||
const ok = await store.reject(job)
|
||||
if (!ok) { stackRef.value?.resetCard(); return }
|
||||
showUndoToast('rejected')
|
||||
checkStoopSpeed()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default defineConfig({
|
|||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8601',
|
||||
target: process.env.VITE_API_TARGET || 'http://localhost:8601',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue