wahnsinn vibe
This commit is contained in:
12
frontend/admin/index.html
Normal file
12
frontend/admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/admin/package.json
Normal file
28
frontend/admin/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@shop/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shop/shared": "workspace:*",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.11",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vue-tsc": "^2.1.8"
|
||||
}
|
||||
}
|
||||
6
frontend/admin/postcss.config.js
Normal file
6
frontend/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
64
frontend/admin/src/App.vue
Normal file
64
frontend/admin/src/App.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||
import HealthBadge from "./components/HealthBadge.vue";
|
||||
import { useAuth } from "./stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.fetchMe();
|
||||
if (!auth.isAdmin && route.path !== "/login") {
|
||||
router.push("/login");
|
||||
}
|
||||
});
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="route.path === '/login'" class="min-h-screen flex items-center justify-center">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-screen flex">
|
||||
<aside class="w-56 bg-admin-700 text-white flex flex-col">
|
||||
<div class="p-4 text-lg font-bold border-b border-admin-600">Shop Admin</div>
|
||||
<nav class="flex-1 p-2 space-y-1 text-sm">
|
||||
<RouterLink to="/" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🏠 Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink to="/products" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
📦 Produkte
|
||||
</RouterLink>
|
||||
<RouterLink to="/categories" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🏷 Kategorien
|
||||
</RouterLink>
|
||||
<RouterLink to="/orders" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🛒 Bestellungen
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
⚙ Einstellungen
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-admin-600 text-xs">
|
||||
<div class="mb-1">{{ auth.user?.email }}</div>
|
||||
<button @click="logout" class="underline">Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">Admin-Panel</div>
|
||||
<HealthBadge />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
2
frontend/admin/src/api.ts
Normal file
2
frontend/admin/src/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createApi } from "@shop/shared/api";
|
||||
export const api = createApi("");
|
||||
137
frontend/admin/src/components/AIChatBox.vue
Normal file
137
frontend/admin/src/components/AIChatBox.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProposalCard as ProposalCardType } from "@shop/shared/types";
|
||||
import { computed, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import ProposalCard from "./ProposalCard.vue";
|
||||
|
||||
interface CardState {
|
||||
card: ProposalCardType;
|
||||
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const prompt = ref("");
|
||||
const planning = ref(false);
|
||||
const planError = ref("");
|
||||
const cards = ref<CardState[]>([]);
|
||||
|
||||
async function plan() {
|
||||
if (!prompt.value.trim()) return;
|
||||
planning.value = true;
|
||||
planError.value = "";
|
||||
try {
|
||||
const r = await api.post("/api/ai_admin/plan", { prompt: prompt.value });
|
||||
cards.value = (r.data.cards || []).map((c: ProposalCardType) => ({ card: c, state: "pending" }));
|
||||
if (!cards.value.length) planError.value = "Die KI konnte keinen Aktionsplan erzeugen.";
|
||||
} catch (e: any) {
|
||||
planError.value = e.response?.data?.detail || e.message;
|
||||
} finally {
|
||||
planning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateArgs(i: number, args: Record<string, any>) {
|
||||
cards.value[i].card.args = args;
|
||||
// remove from 'missing' those that now have values
|
||||
cards.value[i].card.missing = (cards.value[i].card.missing || []).filter(
|
||||
(k) => args[k] === undefined || args[k] === null || args[k] === ""
|
||||
);
|
||||
}
|
||||
|
||||
async function execute(i: number) {
|
||||
const cs = cards.value[i];
|
||||
if (cs.card.missing.length) {
|
||||
cs.state = "error";
|
||||
cs.error = `Fehlende Felder: ${cs.card.missing.join(", ")}`;
|
||||
return;
|
||||
}
|
||||
cs.state = "executing";
|
||||
try {
|
||||
const r = await api.post("/api/ai_admin/execute", {
|
||||
cards: [{ tool: cs.card.tool, args: cs.card.args }],
|
||||
});
|
||||
const res = r.data.results[0];
|
||||
if (res.ok) {
|
||||
cs.state = "success";
|
||||
cs.result = res.result;
|
||||
} else {
|
||||
cs.state = "error";
|
||||
cs.error = res.error;
|
||||
}
|
||||
} catch (e: any) {
|
||||
cs.state = "error";
|
||||
cs.error = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function reject(i: number) {
|
||||
cards.value[i].state = "rejected";
|
||||
}
|
||||
|
||||
async function confirmAll() {
|
||||
for (let i = 0; i < cards.value.length; i++) {
|
||||
if (cards.value[i].state === "pending") {
|
||||
await execute(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
cards.value = [];
|
||||
prompt.value = "";
|
||||
planError.value = "";
|
||||
}
|
||||
|
||||
const pendingCount = computed(() => cards.value.filter((c) => c.state === "pending").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-2">🤖 KI-Assistent</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Sag, was getan werden soll. Die KI erzeugt nur Vorschläge — ausgeführt wird erst nach
|
||||
deiner Bestätigung. Du kannst auch JSON-Daten reinwerfen.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="3"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="z.B. 'setze den Shopnamen auf TEST123' oder [{sku:'NEW-1',...}] erstelle diese"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button @click="plan" :disabled="planning" class="btn-primary">
|
||||
{{ planning ? "Plane..." : "Planen" }}
|
||||
</button>
|
||||
<button v-if="cards.length" @click="reset" class="btn-secondary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="planning" class="text-xs text-gray-500 mt-2">
|
||||
Lokales LLM rechnet. Bei Bulk-Operationen über viele Items kann das ein paar Minuten dauern.
|
||||
</div>
|
||||
<div v-if="planError" class="text-red-600 text-sm mt-2">{{ planError }}</div>
|
||||
|
||||
<div v-if="cards.length" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-medium">{{ cards.length }} Vorschlag(e)</div>
|
||||
<button v-if="pendingCount > 1" @click="confirmAll" class="btn-success text-sm">
|
||||
Alle bestätigen ({{ pendingCount }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<ProposalCard
|
||||
v-for="(cs, i) in cards"
|
||||
:key="i"
|
||||
:card="cs.card"
|
||||
:state="cs.state"
|
||||
:result="cs.result"
|
||||
:error="cs.error"
|
||||
@update:args="(a) => updateArgs(i, a)"
|
||||
@confirm="execute(i)"
|
||||
@reject="reject(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
frontend/admin/src/components/HealthBadge.vue
Normal file
31
frontend/admin/src/components/HealthBadge.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const ok = ref<boolean | null>(null);
|
||||
const info = ref<any>(null);
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const r = await api.get("/health");
|
||||
ok.value = r.data.ok;
|
||||
info.value = r.data;
|
||||
} catch {
|
||||
ok.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(check);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="ok === null ? 'bg-gray-300' : ok ? 'bg-green-500' : 'bg-red-500'"
|
||||
></span>
|
||||
<span>
|
||||
{{ ok === null ? "…" : ok ? `Backend online (${info?.apps?.length} Apps)` : "Backend offline" }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
137
frontend/admin/src/components/ProposalCard.vue
Normal file
137
frontend/admin/src/components/ProposalCard.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProposalCard } from "@shop/shared/types";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
card: ProposalCard;
|
||||
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
|
||||
result?: any;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:args", args: Record<string, any>): void;
|
||||
(e: "confirm"): void;
|
||||
(e: "reject"): void;
|
||||
}>();
|
||||
|
||||
// local editable copy of args
|
||||
const localArgs = ref<Record<string, any>>({ ...props.card.args });
|
||||
|
||||
function update(key: string, value: any) {
|
||||
localArgs.value = { ...localArgs.value, [key]: value };
|
||||
emit("update:args", localArgs.value);
|
||||
}
|
||||
|
||||
const props_ = computed<Record<string, any>>(() => {
|
||||
return (props.card.schema?.properties as Record<string, any>) || {};
|
||||
});
|
||||
|
||||
const requiredKeys = computed<string[]>(
|
||||
() => (props.card.schema?.required as string[]) || []
|
||||
);
|
||||
|
||||
// union of schema keys + keys already present in args (so unusual fields show too)
|
||||
const allKeys = computed<string[]>(() => {
|
||||
const s = new Set<string>([...Object.keys(props_.value), ...Object.keys(localArgs.value || {})]);
|
||||
return Array.from(s);
|
||||
});
|
||||
|
||||
function isMissing(k: string): boolean {
|
||||
return props.card.missing?.includes(k) || false;
|
||||
}
|
||||
|
||||
function inputType(schema: any): string {
|
||||
const t = schema?.type;
|
||||
if (t === "number" || t === "integer") return "number";
|
||||
if (t === "boolean") return "checkbox";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function stringify(v: any) {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card border-l-4"
|
||||
:class="{
|
||||
'border-l-yellow-400': state === 'pending',
|
||||
'border-l-blue-500': state === 'executing' || state === 'confirmed',
|
||||
'border-l-green-500': state === 'success',
|
||||
'border-l-red-500': state === 'error' || state === 'rejected',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500">{{ card.tool }}</div>
|
||||
<div class="font-medium">{{ card.preview }}</div>
|
||||
<div v-if="card.notes" class="text-xs text-gray-500 italic mt-1">{{ card.notes }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-700': state === 'pending',
|
||||
'bg-blue-100 text-blue-700': state === 'executing' || state === 'confirmed',
|
||||
'bg-green-100 text-green-700': state === 'success',
|
||||
'bg-red-100 text-red-700': state === 'error' || state === 'rejected',
|
||||
}"
|
||||
>{{ state }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="allKeys.length" class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div v-for="k in allKeys" :key="k">
|
||||
<label class="label">
|
||||
{{ k }}
|
||||
<span v-if="requiredKeys.includes(k)" class="text-red-500">*</span>
|
||||
<span
|
||||
v-if="isMissing(k)"
|
||||
class="ml-1 text-xs text-red-600 bg-red-50 px-1 rounded"
|
||||
>fehlt</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="inputType(props_[k]) === 'checkbox'"
|
||||
type="checkbox"
|
||||
:checked="!!localArgs[k]"
|
||||
:disabled="state !== 'pending'"
|
||||
@change="(e: any) => update(k, e.target.checked)"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="typeof localArgs[k] === 'object' && localArgs[k] !== null"
|
||||
class="input font-mono text-xs"
|
||||
rows="2"
|
||||
:disabled="state !== 'pending'"
|
||||
:value="stringify(localArgs[k])"
|
||||
@input="(e: any) => {
|
||||
try { update(k, JSON.parse(e.target.value)); } catch {}
|
||||
}"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:type="inputType(props_[k])"
|
||||
:step="props_[k]?.type === 'number' ? 'any' : undefined"
|
||||
:value="stringify(localArgs[k])"
|
||||
:disabled="state !== 'pending'"
|
||||
@input="(e: any) => update(k, props_[k]?.type === 'number' || props_[k]?.type === 'integer' ? Number(e.target.value) : e.target.value)"
|
||||
class="input"
|
||||
:class="{ 'border-red-400': isMissing(k) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state === 'success' && result" class="mt-3 text-sm text-green-700 bg-green-50 p-2 rounded">
|
||||
✓ Ausgeführt: {{ JSON.stringify(result) }}
|
||||
</div>
|
||||
<div v-if="state === 'error' && error" class="mt-3 text-sm text-red-700 bg-red-50 p-2 rounded">
|
||||
✗ {{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="state === 'pending'" class="mt-3 flex gap-2">
|
||||
<button @click="emit('confirm')" class="btn-success">Bestätigen</button>
|
||||
<button @click="emit('reject')" class="btn-secondary">Verwerfen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
10
frontend/admin/src/main.ts
Normal file
10
frontend/admin/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import { router } from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
102
frontend/admin/src/pages/Categories.vue
Normal file
102
frontend/admin/src/pages/Categories.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category } from "@shop/shared/types";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const categories = ref<Category[]>([]);
|
||||
const form = reactive({
|
||||
slug: "",
|
||||
name_de: "",
|
||||
name_en: "",
|
||||
sort_order: 0,
|
||||
});
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
const r = await api.get("/api/catalog/admin/categories");
|
||||
categories.value = r.data;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
error.value = "";
|
||||
try {
|
||||
await api.post("/api/catalog/admin/categories", {
|
||||
slug: form.slug,
|
||||
name: { de: form.name_de, en: form.name_en },
|
||||
sort_order: form.sort_order,
|
||||
});
|
||||
form.slug = "";
|
||||
form.name_de = "";
|
||||
form.name_en = "";
|
||||
form.sort_order = 0;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function del(c: Category) {
|
||||
if (!confirm(`Kategorie ${c.slug} löschen?`)) return;
|
||||
await api.delete(`/api/catalog/admin/categories/${c.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">Kategorien</h1>
|
||||
|
||||
<div class="grid md:grid-cols-[1fr_320px] gap-6">
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Slug</th>
|
||||
<th>Name (DE)</th>
|
||||
<th>Sortierung</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in categories" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td class="font-mono text-xs">{{ c.slug }}</td>
|
||||
<td>{{ c.name.de }}</td>
|
||||
<td>{{ c.sort_order }}</td>
|
||||
<td class="text-right">
|
||||
<button @click="del(c)" class="text-red-500 text-xs">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-3">Neue Kategorie</h2>
|
||||
<form @submit.prevent="create" class="space-y-2">
|
||||
<div>
|
||||
<label class="label">Slug</label>
|
||||
<input v-model="form.slug" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (DE)</label>
|
||||
<input v-model="form.name_de" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (EN)</label>
|
||||
<input v-model="form.name_en" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Sortierung</label>
|
||||
<input v-model.number="form.sort_order" type="number" class="input" />
|
||||
</div>
|
||||
<button class="btn-primary w-full">Erstellen</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
68
frontend/admin/src/pages/Dashboard.vue
Normal file
68
frontend/admin/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import AIChatBox from "../components/AIChatBox.vue";
|
||||
|
||||
const stats = ref({ products: 0, categories: 0, orders: 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [p, c, o] = await Promise.all([
|
||||
api.get("/api/catalog/admin/products"),
|
||||
api.get("/api/catalog/admin/categories"),
|
||||
api.get("/api/orders/admin"),
|
||||
]);
|
||||
stats.value = {
|
||||
products: p.data.length,
|
||||
categories: c.data.length,
|
||||
orders: o.data.length,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Produkte</div>
|
||||
<div class="text-3xl font-bold">{{ stats.products }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Kategorien</div>
|
||||
<div class="text-3xl font-bold">{{ stats.categories }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Bestellungen</div>
|
||||
<div class="text-3xl font-bold">{{ stats.orders }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIChatBox />
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Beispiele für die KI-Box</h2>
|
||||
<ul class="list-disc pl-5 space-y-1 text-sm text-gray-700">
|
||||
<li>
|
||||
<code class="bg-gray-100 px-1">setze den Shopnamen auf TEST123</code>
|
||||
</li>
|
||||
<li>
|
||||
<code class="bg-gray-100 px-1">ändere den Preis von Produkt 1 auf 44.90</code>
|
||||
</li>
|
||||
<li>
|
||||
JSON-Bulk:
|
||||
<pre class="bg-gray-50 p-2 rounded mt-1 text-xs overflow-x-auto">Das sind neue Produkte, erstelle sie:
|
||||
[
|
||||
{"sku":"NEW-TS-RED","name_de":"Rotes T-Shirt","price":18.90,"stock":20},
|
||||
{"sku":"NEW-SOCK","name_de":"Socken","price":4.90,"stock":100},
|
||||
{"sku":"NEW-CAP","name_de":"Grüne Kappe","price":12.00,"stock":30}
|
||||
]</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
frontend/admin/src/pages/Login.vue
Normal file
35
frontend/admin/src/pages/Login.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuth } from "../stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const email = ref("admin@example.com");
|
||||
const password = ref("admin123");
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await auth.login(email.value, password.value);
|
||||
router.push("/");
|
||||
} catch {
|
||||
// message surfaced via auth.error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card w-96">
|
||||
<h1 class="text-2xl font-bold mb-1">Admin-Login</h1>
|
||||
<p class="text-sm text-gray-500 mb-4">Nur für Administratoren.</p>
|
||||
<form @submit.prevent="submit" class="space-y-3">
|
||||
<input v-model="email" type="email" placeholder="E-Mail" class="input" />
|
||||
<input v-model="password" type="password" placeholder="Passwort" class="input" />
|
||||
<button class="btn-primary w-full" :disabled="auth.loading">
|
||||
{{ auth.loading ? "..." : "Anmelden" }}
|
||||
</button>
|
||||
<div v-if="auth.error" class="text-red-600 text-sm">{{ auth.error }}</div>
|
||||
</form>
|
||||
<p class="text-xs text-gray-400 mt-3">Demo: admin@example.com / admin123</p>
|
||||
</div>
|
||||
</template>
|
||||
91
frontend/admin/src/pages/OrderDetail.vue
Normal file
91
frontend/admin/src/pages/OrderDetail.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const order = ref<Order | null>(null);
|
||||
const newStatus = ref("");
|
||||
const note = ref("");
|
||||
const msg = ref("");
|
||||
|
||||
async function load() {
|
||||
const r = await api.get(`/api/orders/admin/${props.id}`);
|
||||
order.value = r.data;
|
||||
newStatus.value = r.data.status;
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
const r = await api.put(`/api/orders/admin/${props.id}/status`, {
|
||||
status: newStatus.value,
|
||||
note: note.value,
|
||||
});
|
||||
order.value = r.data;
|
||||
msg.value = "✓ Status aktualisiert";
|
||||
note.value = "";
|
||||
setTimeout(() => (msg.value = ""), 2000);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="order" class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold mb-2">Bestellung #{{ order.id }}</h1>
|
||||
<div class="text-sm text-gray-500 mb-4">
|
||||
{{ new Date(order.created_at).toLocaleString() }}
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Artikel</h2>
|
||||
<div v-for="it in order.items" :key="it.product_id" class="flex justify-between py-1">
|
||||
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
|
||||
<span>{{ it.line_total.toFixed(2) }} €</span>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="flex justify-between font-bold">
|
||||
<span>Gesamt</span>
|
||||
<span>{{ order.total.toFixed(2) }} {{ order.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Adresse</h2>
|
||||
<div class="text-sm">
|
||||
{{ order.address.name }}<br />
|
||||
{{ order.address.street }}<br />
|
||||
{{ order.address.zip }} {{ order.address.city }}<br />
|
||||
{{ order.address.country }}
|
||||
</div>
|
||||
<h2 class="font-semibold mt-4 mb-2">Zahlung</h2>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ order.payment.method || "dummy" }} — Ref {{ order.payment.transaction_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card md:col-span-2">
|
||||
<h2 class="font-semibold mb-2">Status ändern</h2>
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="label">Neuer Status</label>
|
||||
<select v-model="newStatus" class="input">
|
||||
<option value="paid">paid</option>
|
||||
<option value="packed">packed</option>
|
||||
<option value="shipped">shipped</option>
|
||||
<option value="delivered">delivered</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="label">Notiz</label>
|
||||
<input v-model="note" class="input" />
|
||||
</div>
|
||||
<button @click="updateStatus" class="btn-primary">Übernehmen</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm mt-2">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
52
frontend/admin/src/pages/Orders.vue
Normal file
52
frontend/admin/src/pages/Orders.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const orders = ref<Order[]>([]);
|
||||
|
||||
async function load() {
|
||||
const r = await api.get("/api/orders/admin");
|
||||
orders.value = r.data;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">Bestellungen ({{ orders.length }})</h1>
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Datum</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Gesamt</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id">
|
||||
<td>#{{ o.id }}</td>
|
||||
<td>{{ new Date(o.created_at).toLocaleString() }}</td>
|
||||
<td>{{ o.address?.name || "—" }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs"
|
||||
:class="o.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
>{{ o.status }}</span>
|
||||
</td>
|
||||
<td class="font-semibold">{{ o.total.toFixed(2) }} {{ o.currency }}</td>
|
||||
<td class="text-right">
|
||||
<RouterLink :to="`/orders/${o.id}`" class="text-admin-500 text-xs">Detail</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
156
frontend/admin/src/pages/ProductEdit.vue
Normal file
156
frontend/admin/src/pages/ProductEdit.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const form = ref<Partial<Product>>({
|
||||
sku: "",
|
||||
name: { de: "", en: "" },
|
||||
description: { de: "", en: "" },
|
||||
price: 0,
|
||||
currency: "EUR",
|
||||
stock: 0,
|
||||
active: true,
|
||||
image_url: "",
|
||||
category_id: null,
|
||||
attributes: {},
|
||||
});
|
||||
const categories = ref<Category[]>([]);
|
||||
const msg = ref("");
|
||||
const error = ref("");
|
||||
|
||||
async function loadCategories() {
|
||||
const r = await api.get("/api/catalog/admin/categories");
|
||||
categories.value = r.data;
|
||||
}
|
||||
|
||||
async function loadProduct() {
|
||||
if (!props.id) return;
|
||||
const r = await api.get(`/api/catalog/admin/products`);
|
||||
const p = r.data.find((x: Product) => String(x.id) === props.id);
|
||||
if (p) form.value = p;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
const body = {
|
||||
sku: form.value.sku,
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
price: Number(form.value.price),
|
||||
currency: form.value.currency,
|
||||
stock: Number(form.value.stock),
|
||||
active: form.value.active,
|
||||
image_url: form.value.image_url,
|
||||
category_id: form.value.category_id,
|
||||
attributes: form.value.attributes,
|
||||
};
|
||||
if (props.id) {
|
||||
await api.put(`/api/catalog/admin/products/${props.id}`, body);
|
||||
} else {
|
||||
await api.post(`/api/catalog/admin/products`, body);
|
||||
}
|
||||
msg.value = "Gespeichert";
|
||||
setTimeout(() => router.push("/products"), 500);
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await api.post("/api/catalog/admin/upload", fd);
|
||||
form.value.image_url = r.data.url;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadProduct();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ props.id ? "Produkt bearbeiten" : "Neues Produkt" }}</h1>
|
||||
<form @submit.prevent="save" class="card space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">SKU</label>
|
||||
<input v-model="form.sku" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Kategorie</label>
|
||||
<select v-model="form.category_id" class="input">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">
|
||||
{{ c.name.de }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Name (DE)</label>
|
||||
<input v-model="form.name!.de" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (EN)</label>
|
||||
<input v-model="form.name!.en" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Beschreibung (DE)</label>
|
||||
<textarea v-model="form.description!.de" class="input" rows="3" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Beschreibung (EN)</label>
|
||||
<textarea v-model="form.description!.en" class="input" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="label">Preis</label>
|
||||
<input v-model.number="form.price" type="number" step="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Währung</label>
|
||||
<input v-model="form.currency" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Bestand</label>
|
||||
<input v-model.number="form.stock" type="number" class="input" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.active" type="checkbox" />
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Bild</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="form.image_url" class="w-20 h-20 bg-gray-100 rounded overflow-hidden">
|
||||
<img :src="form.image_url" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<input type="file" accept="image/*" @change="upload" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" @click="router.push('/products')" class="btn-secondary">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm">{{ msg }}</div>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/admin/src/pages/Products.vue
Normal file
67
frontend/admin/src/pages/Products.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const products = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.get("/api/catalog/admin/products");
|
||||
products.value = r.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function del(p: Product) {
|
||||
if (!confirm(`Produkt ${p.sku} wirklich löschen?`)) return;
|
||||
await api.delete(`/api/catalog/admin/products/${p.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Produkte ({{ products.length }})</h1>
|
||||
<RouterLink to="/products/new" class="btn-primary">+ Neues Produkt</RouterLink>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-if="loading" class="text-gray-500">Lädt...</div>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>SKU</th>
|
||||
<th>Name (DE)</th>
|
||||
<th>Preis</th>
|
||||
<th>Lager</th>
|
||||
<th>Aktiv</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in products" :key="p.id">
|
||||
<td>{{ p.id }}</td>
|
||||
<td class="font-mono text-xs">{{ p.sku }}</td>
|
||||
<td>{{ i18n.pickI18n(p.name, "de") }}</td>
|
||||
<td>{{ p.price.toFixed(2) }} €</td>
|
||||
<td>{{ p.stock }}</td>
|
||||
<td>{{ p.active ? "✓" : "–" }}</td>
|
||||
<td class="text-right">
|
||||
<RouterLink :to="`/products/${p.id}`" class="text-admin-500 text-xs mr-2">Bearbeiten</RouterLink>
|
||||
<button @click="del(p)" class="text-red-500 text-xs">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
frontend/admin/src/pages/Settings.vue
Normal file
69
frontend/admin/src/pages/Settings.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const shopName = ref("");
|
||||
const msg = ref("");
|
||||
const reindexing = ref(false);
|
||||
const reindexResult = ref<any>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await api.get("/api/core/settings/core.shop_name");
|
||||
shopName.value = r.data.value || "";
|
||||
} catch {
|
||||
shopName.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
await api.put("/api/core/settings/core.shop_name", { value: shopName.value });
|
||||
msg.value = "✓ Gespeichert";
|
||||
setTimeout(() => (msg.value = ""), 2000);
|
||||
}
|
||||
|
||||
async function reindex() {
|
||||
reindexing.value = true;
|
||||
reindexResult.value = null;
|
||||
try {
|
||||
const r = await api.post("/api/ai_core/reindex");
|
||||
reindexResult.value = r.data;
|
||||
} finally {
|
||||
reindexing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-bold mb-4">Einstellungen</h1>
|
||||
|
||||
<div class="card space-y-3">
|
||||
<div>
|
||||
<label class="label">Shop-Name (core.shop_name)</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="shopName" class="input" />
|
||||
<button @click="save" class="btn-primary">Speichern</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm mt-1">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 space-y-3">
|
||||
<h2 class="font-semibold">KI-Index</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Baut Embeddings für alle Produkte und Kategorien neu auf (nötig nach Seed oder bei
|
||||
Inkonsistenzen).
|
||||
</p>
|
||||
<button @click="reindex" :disabled="reindexing" class="btn-secondary">
|
||||
{{ reindexing ? "Indexiere..." : "Neu indizieren" }}
|
||||
</button>
|
||||
<div v-if="reindexResult" class="text-sm text-green-700">
|
||||
✓ {{ reindexResult.products }} Produkte, {{ reindexResult.categories }} Kategorien
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
24
frontend/admin/src/router.ts
Normal file
24
frontend/admin/src/router.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Categories from "./pages/Categories.vue";
|
||||
import Dashboard from "./pages/Dashboard.vue";
|
||||
import Login from "./pages/Login.vue";
|
||||
import OrderDetail from "./pages/OrderDetail.vue";
|
||||
import Orders from "./pages/Orders.vue";
|
||||
import ProductEdit from "./pages/ProductEdit.vue";
|
||||
import Products from "./pages/Products.vue";
|
||||
import Settings from "./pages/Settings.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/login", component: Login },
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/products", component: Products },
|
||||
{ path: "/products/new", component: ProductEdit },
|
||||
{ path: "/products/:id", component: ProductEdit, props: true },
|
||||
{ path: "/categories", component: Categories },
|
||||
{ path: "/orders", component: Orders },
|
||||
{ path: "/orders/:id", component: OrderDetail, props: true },
|
||||
{ path: "/settings", component: Settings },
|
||||
],
|
||||
});
|
||||
47
frontend/admin/src/stores/auth.ts
Normal file
47
frontend/admin/src/stores/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { clearTokens, saveTokens } from "@shop/shared/api";
|
||||
import type { User } from "@shop/shared/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "../api";
|
||||
|
||||
export const useAuth = defineStore("auth", {
|
||||
state: () => ({
|
||||
user: null as User | null,
|
||||
loading: false,
|
||||
error: "",
|
||||
}),
|
||||
getters: {
|
||||
isAdmin: (s) => s.user?.role === "admin",
|
||||
},
|
||||
actions: {
|
||||
async fetchMe() {
|
||||
try {
|
||||
const r = await api.get("/api/auth/me");
|
||||
this.user = r.data;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const r = await api.post("/api/auth/login", { email, password });
|
||||
if (r.data.role !== "admin") {
|
||||
clearTokens();
|
||||
throw new Error("Nur Admins dürfen sich hier anmelden.");
|
||||
}
|
||||
saveTokens(r.data.access_token, r.data.refresh_token);
|
||||
await this.fetchMe();
|
||||
} catch (e: any) {
|
||||
this.error = e.response?.data?.detail || e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
clearTokens();
|
||||
this.user = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
42
frontend/admin/src/style.css
Normal file
42
frontend/admin/src/style.css
Normal file
@@ -0,0 +1,42 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-admin-500 text-white hover:bg-admin-600 disabled:opacity-50;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn border border-gray-300 bg-white text-gray-700 hover:bg-gray-50;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-500 text-white hover:bg-red-600;
|
||||
}
|
||||
.btn-success {
|
||||
@apply btn bg-green-500 text-white hover:bg-green-600;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-gray-300 px-3 py-2 focus:border-admin-500 focus:outline-none focus:ring-1 focus:ring-admin-500;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg bg-white p-4 shadow-sm border border-gray-200;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
table.data-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
table.data-table th {
|
||||
@apply text-left font-medium text-gray-600 border-b border-gray-200 px-2 py-2;
|
||||
}
|
||||
table.data-table td {
|
||||
@apply border-b border-gray-100 px-2 py-2;
|
||||
}
|
||||
16
frontend/admin/tailwind.config.js
Normal file
16
frontend/admin/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
admin: {
|
||||
500: "#1565c0",
|
||||
600: "#134a92",
|
||||
700: "#0d3778",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
22
frontend/admin/tsconfig.json
Normal file
22
frontend/admin/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@shop/shared": ["../shared/src/index.ts"],
|
||||
"@shop/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.vue"]
|
||||
}
|
||||
15
frontend/admin/vite.config.ts
Normal file
15
frontend/admin/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/uploads": "http://localhost:8000",
|
||||
"/health": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
5
frontend/package.json
Normal file
5
frontend/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "shop-frontend-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
1832
frontend/pnpm-lock.yaml
generated
Normal file
1832
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/pnpm-workspace.yaml
Normal file
6
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
packages:
|
||||
- "shared"
|
||||
- "shop"
|
||||
- "admin"
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
16
frontend/shared/package.json
Normal file
16
frontend/shared/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@shop/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./api": "./src/api.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./i18n": "./src/i18n/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7"
|
||||
}
|
||||
}
|
||||
60
frontend/shared/src/api.ts
Normal file
60
frontend/shared/src/api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
|
||||
const STORAGE_KEY_ACCESS = "shop_access_token";
|
||||
const STORAGE_KEY_REFRESH = "shop_refresh_token";
|
||||
|
||||
export function createApi(baseURL: string): AxiosInstance {
|
||||
// 10 min — LLM plan calls over many items on a local CPU can take several minutes.
|
||||
const api = axios.create({ baseURL, timeout: 600000 });
|
||||
|
||||
api.interceptors.request.use((cfg) => {
|
||||
const token = localStorage.getItem(STORAGE_KEY_ACCESS);
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
async (err) => {
|
||||
const original = err.config;
|
||||
if (err.response?.status === 401 && !original._retry) {
|
||||
const refresh = localStorage.getItem(STORAGE_KEY_REFRESH);
|
||||
if (refresh) {
|
||||
original._retry = true;
|
||||
try {
|
||||
const resp = await axios.post(`${baseURL}/api/auth/refresh`, {
|
||||
refresh_token: refresh,
|
||||
});
|
||||
localStorage.setItem(STORAGE_KEY_ACCESS, resp.data.access_token);
|
||||
localStorage.setItem(STORAGE_KEY_REFRESH, resp.data.refresh_token);
|
||||
original.headers.Authorization = `Bearer ${resp.data.access_token}`;
|
||||
return api(original);
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY_ACCESS);
|
||||
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
export function saveTokens(access: string, refresh: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_ACCESS, access);
|
||||
localStorage.setItem(STORAGE_KEY_REFRESH, refresh);
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(STORAGE_KEY_ACCESS);
|
||||
localStorage.removeItem(STORAGE_KEY_REFRESH);
|
||||
}
|
||||
|
||||
export function hasAccess(): boolean {
|
||||
return !!localStorage.getItem(STORAGE_KEY_ACCESS);
|
||||
}
|
||||
59
frontend/shared/src/i18n/de.ts
Normal file
59
frontend/shared/src/i18n/de.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export const de: Record<string, string> = {
|
||||
// common
|
||||
"common.loading": "Lädt...",
|
||||
"common.save": "Speichern",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.delete": "Löschen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.confirm": "Bestätigen",
|
||||
"common.back": "Zurück",
|
||||
"common.name": "Name",
|
||||
"common.email": "E-Mail",
|
||||
"common.password": "Passwort",
|
||||
"common.total": "Gesamt",
|
||||
"common.empty": "Keine Einträge",
|
||||
|
||||
// shop
|
||||
"shop.title": "Shop",
|
||||
"shop.home": "Startseite",
|
||||
"shop.categories": "Kategorien",
|
||||
"shop.cart": "Warenkorb",
|
||||
"shop.account": "Mein Konto",
|
||||
"shop.orders": "Meine Bestellungen",
|
||||
"shop.login": "Anmelden",
|
||||
"shop.register": "Registrieren",
|
||||
"shop.logout": "Abmelden",
|
||||
"shop.search_placeholder": "Was suchst du? (z.B. 'grüner Pulli')",
|
||||
"shop.add_to_cart": "In den Warenkorb",
|
||||
"shop.in_stock": "Auf Lager",
|
||||
"shop.out_of_stock": "Ausverkauft",
|
||||
"shop.checkout": "Zur Kasse",
|
||||
"shop.order_placed": "Bestellung aufgegeben",
|
||||
"shop.empty_cart": "Warenkorb ist leer",
|
||||
"shop.continue_shopping": "Weiter einkaufen",
|
||||
"shop.delivery_address": "Lieferadresse",
|
||||
"shop.payment_method": "Zahlungsart",
|
||||
"shop.place_order": "Jetzt bestellen",
|
||||
"shop.street": "Straße",
|
||||
"shop.zip": "PLZ",
|
||||
"shop.city": "Stadt",
|
||||
"shop.country": "Land",
|
||||
"shop.no_products": "Keine Produkte gefunden",
|
||||
|
||||
// admin
|
||||
"admin.title": "Admin",
|
||||
"admin.dashboard": "Dashboard",
|
||||
"admin.products": "Produkte",
|
||||
"admin.categories": "Kategorien",
|
||||
"admin.orders": "Bestellungen",
|
||||
"admin.settings": "Einstellungen",
|
||||
"admin.chat_placeholder":
|
||||
"Gib einen Befehl oder wirf JSON rein (z.B. 'setze den Shopnamen auf TEST123')",
|
||||
"admin.send": "Senden",
|
||||
"admin.proposals": "Vorschläge",
|
||||
"admin.confirm_all": "Alle bestätigen",
|
||||
"admin.missing": "Fehlt",
|
||||
"admin.add_product": "Produkt hinzufügen",
|
||||
"admin.add_category": "Kategorie hinzufügen",
|
||||
"admin.new_status": "Neuer Status",
|
||||
};
|
||||
56
frontend/shared/src/i18n/en.ts
Normal file
56
frontend/shared/src/i18n/en.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const en: Record<string, string> = {
|
||||
"common.loading": "Loading...",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.confirm": "Confirm",
|
||||
"common.back": "Back",
|
||||
"common.name": "Name",
|
||||
"common.email": "Email",
|
||||
"common.password": "Password",
|
||||
"common.total": "Total",
|
||||
"common.empty": "No entries",
|
||||
|
||||
"shop.title": "Shop",
|
||||
"shop.home": "Home",
|
||||
"shop.categories": "Categories",
|
||||
"shop.cart": "Cart",
|
||||
"shop.account": "My account",
|
||||
"shop.orders": "My orders",
|
||||
"shop.login": "Log in",
|
||||
"shop.register": "Sign up",
|
||||
"shop.logout": "Log out",
|
||||
"shop.search_placeholder": "What are you looking for? (e.g. 'green sweater')",
|
||||
"shop.add_to_cart": "Add to cart",
|
||||
"shop.in_stock": "In stock",
|
||||
"shop.out_of_stock": "Out of stock",
|
||||
"shop.checkout": "Checkout",
|
||||
"shop.order_placed": "Order placed",
|
||||
"shop.empty_cart": "Cart is empty",
|
||||
"shop.continue_shopping": "Continue shopping",
|
||||
"shop.delivery_address": "Delivery address",
|
||||
"shop.payment_method": "Payment method",
|
||||
"shop.place_order": "Place order",
|
||||
"shop.street": "Street",
|
||||
"shop.zip": "ZIP",
|
||||
"shop.city": "City",
|
||||
"shop.country": "Country",
|
||||
"shop.no_products": "No products found",
|
||||
|
||||
"admin.title": "Admin",
|
||||
"admin.dashboard": "Dashboard",
|
||||
"admin.products": "Products",
|
||||
"admin.categories": "Categories",
|
||||
"admin.orders": "Orders",
|
||||
"admin.settings": "Settings",
|
||||
"admin.chat_placeholder":
|
||||
"Enter a command or paste JSON (e.g. 'set the shop name to TEST123')",
|
||||
"admin.send": "Send",
|
||||
"admin.proposals": "Proposals",
|
||||
"admin.confirm_all": "Confirm all",
|
||||
"admin.missing": "Missing",
|
||||
"admin.add_product": "Add product",
|
||||
"admin.add_category": "Add category",
|
||||
"admin.new_status": "New status",
|
||||
};
|
||||
26
frontend/shared/src/i18n/index.ts
Normal file
26
frontend/shared/src/i18n/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { I18nText, Locale } from "../types";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
|
||||
const dicts: Record<Locale, Record<string, string>> = { de, en };
|
||||
|
||||
let current: Locale = (localStorage.getItem("locale") as Locale) || "de";
|
||||
|
||||
export function setLocale(loc: Locale) {
|
||||
current = loc;
|
||||
localStorage.setItem("locale", loc);
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function t(key: string): string {
|
||||
return dicts[current]?.[key] || dicts.de[key] || key;
|
||||
}
|
||||
|
||||
export function pickI18n(txt: I18nText | undefined | null, loc?: Locale): string {
|
||||
if (!txt) return "";
|
||||
const l = loc || current;
|
||||
return txt[l] || txt.de || txt.en || "";
|
||||
}
|
||||
3
frontend/shared/src/index.ts
Normal file
3
frontend/shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./api";
|
||||
export * as i18n from "./i18n";
|
||||
89
frontend/shared/src/types.ts
Normal file
89
frontend/shared/src/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export type Locale = "de" | "en";
|
||||
|
||||
export interface I18nText {
|
||||
de?: string;
|
||||
en?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "customer" | "admin";
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: I18nText;
|
||||
parent_id: number | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
sku: string;
|
||||
name: I18nText;
|
||||
description: I18nText;
|
||||
price: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
category_id: number | null;
|
||||
attributes: Record<string, any>;
|
||||
_score?: number;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
product_id: number;
|
||||
qty: number;
|
||||
name: I18nText;
|
||||
price: number;
|
||||
image_url: string;
|
||||
line_total: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
items: CartItem[];
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
product_id: number;
|
||||
sku: string;
|
||||
name: I18nText;
|
||||
price: number;
|
||||
qty: number;
|
||||
line_total: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
status: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
address: Record<string, string>;
|
||||
payment: Record<string, any>;
|
||||
items: OrderItem[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProposalCard {
|
||||
tool: string;
|
||||
args: Record<string, any>;
|
||||
missing: string[];
|
||||
preview: string;
|
||||
notes?: string;
|
||||
schema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
role: string;
|
||||
user_id: number;
|
||||
}
|
||||
12
frontend/shop/index.html
Normal file
12
frontend/shop/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/shop/package.json
Normal file
28
frontend/shop/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@shop/shop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shop/shared": "workspace:*",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.11",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vue-tsc": "^2.1.8"
|
||||
}
|
||||
}
|
||||
6
frontend/shop/postcss.config.js
Normal file
6
frontend/shop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
67
frontend/shop/src/App.vue
Normal file
67
frontend/shop/src/App.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
import HealthBadge from "./components/HealthBadge.vue";
|
||||
import { useAuth } from "./stores/auth";
|
||||
import { useCart } from "./stores/cart";
|
||||
import { useShop } from "./stores/shop";
|
||||
|
||||
const auth = useAuth();
|
||||
const cart = useCart();
|
||||
const shop = useShop();
|
||||
|
||||
onMounted(async () => {
|
||||
await shop.loadAll();
|
||||
await auth.fetchMe();
|
||||
if (auth.isAuthenticated) await cart.load();
|
||||
});
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
cart.cart = { items: [], subtotal: 0 };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<RouterLink to="/" class="text-xl font-bold text-brand-500">{{ shop.shopName }}</RouterLink>
|
||||
<nav class="flex gap-3 text-sm text-gray-600">
|
||||
<RouterLink to="/" class="hover:text-brand-500">{{ i18n.t("shop.home") }}</RouterLink>
|
||||
<RouterLink to="/ai" class="hover:text-brand-500">KI-Suche</RouterLink>
|
||||
</nav>
|
||||
<div class="flex-1"></div>
|
||||
<HealthBadge />
|
||||
<RouterLink to="/cart" class="relative text-gray-700 hover:text-brand-500">
|
||||
{{ i18n.t("shop.cart") }}
|
||||
<span
|
||||
v-if="cart.count"
|
||||
class="absolute -top-2 -right-3 bg-brand-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
|
||||
>{{ cart.count }}</span>
|
||||
</RouterLink>
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<RouterLink to="/account" class="text-gray-700 hover:text-brand-500">
|
||||
{{ i18n.t("shop.account") }}
|
||||
</RouterLink>
|
||||
<button @click="logout" class="text-gray-600 hover:text-brand-500 text-sm">
|
||||
{{ i18n.t("shop.logout") }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/login" class="text-gray-700 hover:text-brand-500">{{ i18n.t("shop.login") }}</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary text-sm">{{ i18n.t("shop.register") }}</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-gray-200 py-6 text-center text-sm text-gray-500">
|
||||
{{ shop.shopName }} — Prototyp
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
3
frontend/shop/src/api.ts
Normal file
3
frontend/shop/src/api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createApi } from "@shop/shared/api";
|
||||
|
||||
export const api = createApi("");
|
||||
46
frontend/shop/src/components/AISearchBar.vue
Normal file
46
frontend/shop/src/components/AISearchBar.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const emit = defineEmits<{ (e: "results", r: { query: string; products: Product[] }): void }>();
|
||||
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function search() {
|
||||
if (!q.value.trim()) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const r = await api.post("/api/ai_shop/search", { query: q.value, limit: 12 });
|
||||
emit("results", r.data);
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message || "Fehler";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="q"
|
||||
@keyup.enter="search"
|
||||
:placeholder="i18n.t('shop.search_placeholder')"
|
||||
class="input"
|
||||
/>
|
||||
<button @click="search" class="btn-primary" :disabled="loading">
|
||||
{{ loading ? "..." : "KI-Suche" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="text-red-600 text-sm mt-2">{{ error }}</div>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
Lokale KI (Ollama + Embeddings) durchsucht Produkte semantisch.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
frontend/shop/src/components/HealthBadge.vue
Normal file
32
frontend/shop/src/components/HealthBadge.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const ok = ref<boolean | null>(null);
|
||||
const info = ref<any>(null);
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const r = await api.get("/health");
|
||||
ok.value = r.data.ok;
|
||||
info.value = r.data;
|
||||
} catch {
|
||||
ok.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(check);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:title="info ? `DB:${info.db} Redis:${info.redis} Apps:${info.apps?.length}` : ''"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="ok === null ? 'bg-gray-300' : ok ? 'bg-green-500' : 'bg-red-500'"
|
||||
></span>
|
||||
<span class="text-gray-500">{{ ok === null ? "..." : ok ? "online" : "offline" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
25
frontend/shop/src/components/ProductCard.vue
Normal file
25
frontend/shop/src/components/ProductCard.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
defineProps<{ product: Product }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink :to="`/product/${product.id}`" class="card block hover:shadow-md transition">
|
||||
<div class="aspect-square bg-gray-100 rounded-md overflow-hidden mb-3">
|
||||
<img :src="product.image_url" :alt="i18n.pickI18n(product.name)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="font-medium">{{ i18n.pickI18n(product.name) }}</div>
|
||||
<div class="text-sm text-gray-500 mb-2">{{ product.sku }}</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-brand-600">
|
||||
{{ product.price.toFixed(2) }} {{ product.currency }}
|
||||
</div>
|
||||
<div class="text-xs" :class="product.stock > 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ product.stock > 0 ? i18n.t("shop.in_stock") : i18n.t("shop.out_of_stock") }}
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
10
frontend/shop/src/main.ts
Normal file
10
frontend/shop/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import { router } from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
30
frontend/shop/src/pages/AISearch.vue
Normal file
30
frontend/shop/src/pages/AISearch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { ref } from "vue";
|
||||
import AISearchBar from "../components/AISearchBar.vue";
|
||||
import ProductCard from "../components/ProductCard.vue";
|
||||
|
||||
const results = ref<Product[]>([]);
|
||||
const query = ref("");
|
||||
|
||||
function onResults(r: { query: string; products: Product[] }) {
|
||||
query.value = r.query;
|
||||
results.value = r.products;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">KI-Produktsuche</h1>
|
||||
<AISearchBar @results="onResults" />
|
||||
<div v-if="query" class="mt-6">
|
||||
<div class="text-sm text-gray-600 mb-3">
|
||||
Ergebnisse für: <span class="font-semibold">{{ query }}</span>
|
||||
</div>
|
||||
<div v-if="!results.length" class="text-gray-500">Keine Treffer.</div>
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ProductCard v-for="p in results" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
95
frontend/shop/src/pages/Account.vue
Normal file
95
frontend/shop/src/pages/Account.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const name = ref("");
|
||||
const locale = ref("de");
|
||||
const msg = ref("");
|
||||
const old_password = ref("");
|
||||
const new_password = ref("");
|
||||
const pwMsg = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
await auth.fetchMe();
|
||||
if (auth.user) {
|
||||
name.value = auth.user.name;
|
||||
locale.value = auth.user.locale;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
try {
|
||||
await api.put("/api/auth/me", { name: name.value, locale: locale.value });
|
||||
await auth.fetchMe();
|
||||
msg.value = "✓ Gespeichert";
|
||||
} catch (e: any) {
|
||||
msg.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePw() {
|
||||
pwMsg.value = "";
|
||||
try {
|
||||
await api.post("/api/auth/change-password", {
|
||||
old_password: old_password.value,
|
||||
new_password: new_password.value,
|
||||
});
|
||||
pwMsg.value = "✓ Passwort geändert";
|
||||
old_password.value = "";
|
||||
new_password.value = "";
|
||||
} catch (e: any) {
|
||||
pwMsg.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="auth.user" class="grid md:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-3">Persönliche Daten</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">{{ i18n.t("common.email") }}</label>
|
||||
<div class="text-gray-900">{{ auth.user.email }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">{{ i18n.t("common.name") }}</label>
|
||||
<input v-model="name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">Sprache</label>
|
||||
<select v-model="locale" class="input">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="save" class="btn-primary">{{ i18n.t("common.save") }}</button>
|
||||
<div v-if="msg" class="text-sm text-green-600">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold mb-3">Passwort ändern</h2>
|
||||
<div class="space-y-3">
|
||||
<input v-model="old_password" type="password" placeholder="Altes Passwort" class="input" />
|
||||
<input v-model="new_password" type="password" placeholder="Neues Passwort" class="input" />
|
||||
<button @click="changePw" class="btn-secondary">Ändern</button>
|
||||
<div v-if="pwMsg" class="text-sm">{{ pwMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<RouterLink to="/account/orders" class="btn-primary">{{ i18n.t("shop.orders") }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
52
frontend/shop/src/pages/Cart.vue
Normal file
52
frontend/shop/src/pages/Cart.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../stores/auth";
|
||||
import { useCart } from "../stores/cart";
|
||||
|
||||
const cart = useCart();
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
if (!auth.isAuthenticated) router.push("/login");
|
||||
else cart.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">{{ i18n.t("shop.cart") }}</h1>
|
||||
<div v-if="!cart.cart.items.length" class="card text-gray-500 text-center py-8">
|
||||
{{ i18n.t("shop.empty_cart") }}
|
||||
<div class="mt-3">
|
||||
<RouterLink to="/" class="btn-primary">{{ i18n.t("shop.continue_shopping") }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="it in cart.cart.items" :key="it.product_id" class="card flex items-center gap-4">
|
||||
<img :src="it.image_url" class="w-16 h-16 object-cover rounded" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ i18n.pickI18n(it.name) }}</div>
|
||||
<div class="text-sm text-gray-500">{{ it.price.toFixed(2) }} €</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="it.qty"
|
||||
@change="(e: any) => cart.update(it.product_id, parseInt(e.target.value))"
|
||||
class="w-16 input"
|
||||
/>
|
||||
<div class="font-semibold w-20 text-right">{{ it.line_total.toFixed(2) }} €</div>
|
||||
<button @click="cart.remove(it.product_id)" class="text-red-500 text-sm">×</button>
|
||||
</div>
|
||||
<div class="card flex items-center justify-between">
|
||||
<div class="text-xl font-bold">
|
||||
{{ i18n.t("common.total") }}: {{ cart.cart.subtotal.toFixed(2) }} €
|
||||
</div>
|
||||
<RouterLink to="/checkout" class="btn-primary">{{ i18n.t("shop.checkout") }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
45
frontend/shop/src/pages/Category.vue
Normal file
45
frontend/shop/src/pages/Category.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Category, Product } from "@shop/shared/types";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api";
|
||||
import ProductCard from "../components/ProductCard.vue";
|
||||
import { useShop } from "../stores/shop";
|
||||
|
||||
const props = defineProps<{ slug: string }>();
|
||||
const shop = useShop();
|
||||
const products = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const category = computed<Category | undefined>(() =>
|
||||
shop.categories.find((c) => c.slug === props.slug)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
if (!category.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.get(`/api/catalog/categories/${category.value.id}/products`);
|
||||
products.value = r.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.slug, load);
|
||||
watch(() => shop.categories.length, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">
|
||||
{{ category ? i18n.pickI18n(category.name) : props.slug }}
|
||||
</h1>
|
||||
<div v-if="loading" class="text-gray-500">{{ i18n.t("common.loading") }}</div>
|
||||
<div v-else-if="!products.length" class="text-gray-500">{{ i18n.t("shop.no_products") }}</div>
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ProductCard v-for="p in products" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
105
frontend/shop/src/pages/Checkout.vue
Normal file
105
frontend/shop/src/pages/Checkout.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../stores/auth";
|
||||
import { useCart } from "../stores/cart";
|
||||
|
||||
const cart = useCart();
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const address = reactive({
|
||||
name: "",
|
||||
street: "",
|
||||
zip: "",
|
||||
city: "",
|
||||
country: "DE",
|
||||
});
|
||||
const payment_method = ref("dummy");
|
||||
const processing = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref<{ order_id: number | null } | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
await cart.load();
|
||||
if (auth.user) address.name = auth.user.name;
|
||||
});
|
||||
|
||||
async function placeOrder() {
|
||||
error.value = "";
|
||||
processing.value = true;
|
||||
try {
|
||||
const r = await api.post("/api/checkout/confirm", { address, payment_method: payment_method.value });
|
||||
success.value = r.data;
|
||||
await cart.load();
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="success" class="card text-center">
|
||||
<h1 class="text-2xl font-bold text-brand-600 mb-2">✓ {{ i18n.t("shop.order_placed") }}</h1>
|
||||
<p v-if="success.order_id">
|
||||
Bestellnummer <strong>#{{ success.order_id }}</strong>. Bestätigungs-Mail versendet.
|
||||
</p>
|
||||
<div class="mt-4 flex gap-2 justify-center">
|
||||
<button @click="router.push('/account/orders')" class="btn-primary">Meine Bestellungen</button>
|
||||
<button @click="router.push('/')" class="btn-secondary">{{ i18n.t("shop.continue_shopping") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-3">{{ i18n.t("shop.delivery_address") }}</h2>
|
||||
<div class="card space-y-3">
|
||||
<input v-model="address.name" class="input" :placeholder="i18n.t('common.name')" />
|
||||
<input v-model="address.street" class="input" :placeholder="i18n.t('shop.street')" />
|
||||
<div class="flex gap-2">
|
||||
<input v-model="address.zip" class="input" :placeholder="i18n.t('shop.zip')" />
|
||||
<input v-model="address.city" class="input" :placeholder="i18n.t('shop.city')" />
|
||||
</div>
|
||||
<input v-model="address.country" class="input" :placeholder="i18n.t('shop.country')" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4 mb-3">{{ i18n.t("shop.payment_method") }}</h2>
|
||||
<div class="card">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" v-model="payment_method" value="dummy" />
|
||||
Testzahlung (immer erfolgreich)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold mb-3">Zusammenfassung</h2>
|
||||
<div class="card space-y-2">
|
||||
<div v-for="it in cart.cart.items" :key="it.product_id" class="flex justify-between">
|
||||
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
|
||||
<span>{{ it.line_total.toFixed(2) }} €</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span>{{ i18n.t("common.total") }}</span>
|
||||
<span>{{ cart.cart.subtotal.toFixed(2) }} €</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="text-red-600 mt-2">{{ error }}</div>
|
||||
<button
|
||||
@click="placeOrder"
|
||||
:disabled="processing || !cart.cart.items.length"
|
||||
class="btn-primary w-full mt-4"
|
||||
>
|
||||
{{ processing ? "..." : i18n.t("shop.place_order") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
frontend/shop/src/pages/Home.vue
Normal file
44
frontend/shop/src/pages/Home.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ProductCard from "../components/ProductCard.vue";
|
||||
import { useShop } from "../stores/shop";
|
||||
|
||||
const shop = useShop();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="mb-6">
|
||||
<div class="bg-gradient-to-br from-brand-500 to-brand-700 text-white rounded-lg p-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Willkommen im {{ shop.shopName }}</h1>
|
||||
<p class="opacity-90">Entdecke Kleidung für jeden Anlass. Mit KI-Suche.</p>
|
||||
<RouterLink to="/ai" class="inline-block mt-4 bg-white text-brand-600 px-4 py-2 rounded-md font-medium">
|
||||
KI-Suche ausprobieren →
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-3">{{ i18n.t("shop.categories") }}</h2>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<RouterLink
|
||||
v-for="c in shop.categories"
|
||||
:key="c.id"
|
||||
:to="`/category/${c.slug}`"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ i18n.pickI18n(c.name) }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-3">Alle Produkte</h2>
|
||||
<div v-if="shop.loading" class="text-gray-500">{{ i18n.t("common.loading") }}</div>
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ProductCard v-for="p in shop.products" :key="p.id" :product="p" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
45
frontend/shop/src/pages/Login.vue
Normal file
45
frontend/shop/src/pages/Login.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { ref } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../stores/auth";
|
||||
import { useCart } from "../stores/cart";
|
||||
|
||||
const auth = useAuth();
|
||||
const cart = useCart();
|
||||
const router = useRouter();
|
||||
|
||||
const email = ref("kunde@example.com");
|
||||
const password = ref("kunde123");
|
||||
const error = ref("");
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(email.value, password.value);
|
||||
await cart.load();
|
||||
router.push("/");
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-sm mx-auto card">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ i18n.t("shop.login") }}</h1>
|
||||
<form @submit.prevent="submit" class="space-y-3">
|
||||
<input v-model="email" type="email" :placeholder="i18n.t('common.email')" class="input" />
|
||||
<input v-model="password" type="password" :placeholder="i18n.t('common.password')" class="input" />
|
||||
<button class="btn-primary w-full" :disabled="auth.loading">
|
||||
{{ auth.loading ? "..." : i18n.t("shop.login") }}
|
||||
</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
<p class="text-sm text-gray-500 mt-4">
|
||||
Noch kein Konto?
|
||||
<RouterLink to="/register" class="text-brand-500">Registrieren</RouterLink>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">Demo: kunde@example.com / kunde123</p>
|
||||
</div>
|
||||
</template>
|
||||
43
frontend/shop/src/pages/OrderDetail.vue
Normal file
43
frontend/shop/src/pages/OrderDetail.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const order = ref<Order | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const r = await api.get(`/api/orders/${props.id}`);
|
||||
order.value = r.data;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="order">
|
||||
<h1 class="text-2xl font-bold mb-2">Bestellung #{{ order.id }}</h1>
|
||||
<div class="text-sm text-gray-500 mb-4">
|
||||
{{ new Date(order.created_at).toLocaleString() }} — Status: {{ order.status }}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-for="it in order.items" :key="it.product_id" class="flex justify-between py-1">
|
||||
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
|
||||
<span>{{ it.line_total.toFixed(2) }} €</span>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="flex justify-between font-bold">
|
||||
<span>{{ i18n.t("common.total") }}</span>
|
||||
<span>{{ order.total.toFixed(2) }} {{ order.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-4">
|
||||
<h2 class="font-semibold mb-2">{{ i18n.t("shop.delivery_address") }}</h2>
|
||||
<div class="text-sm">
|
||||
{{ order.address.name }}<br />
|
||||
{{ order.address.street }}<br />
|
||||
{{ order.address.zip }} {{ order.address.city }}<br />
|
||||
{{ order.address.country }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
frontend/shop/src/pages/Orders.vue
Normal file
54
frontend/shop/src/pages/Orders.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const orders = ref<Order[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAuthenticated) return router.push("/login");
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.get("/api/orders");
|
||||
orders.value = r.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">{{ i18n.t("shop.orders") }}</h1>
|
||||
<div v-if="loading" class="text-gray-500">{{ i18n.t("common.loading") }}</div>
|
||||
<div v-else-if="!orders.length" class="text-gray-500">Noch keine Bestellungen.</div>
|
||||
<div v-else class="space-y-2">
|
||||
<RouterLink
|
||||
v-for="o in orders"
|
||||
:key="o.id"
|
||||
:to="`/account/orders/${o.id}`"
|
||||
class="card flex justify-between items-center hover:shadow-md"
|
||||
>
|
||||
<div>
|
||||
<div class="font-semibold">#{{ o.id }}</div>
|
||||
<div class="text-sm text-gray-500">{{ new Date(o.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold">{{ o.total.toFixed(2) }} {{ o.currency }}</div>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs"
|
||||
:class="o.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
>{{ o.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
65
frontend/shop/src/pages/Product.vue
Normal file
65
frontend/shop/src/pages/Product.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../stores/auth";
|
||||
import { useCart } from "../stores/cart";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const product = ref<Product | null>(null);
|
||||
const cart = useCart();
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const msg = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.get(`/api/catalog/products/${props.id}`);
|
||||
product.value = r.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addToCart() {
|
||||
if (!auth.isAuthenticated) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (!product.value) return;
|
||||
await cart.add(product.value.id, 1);
|
||||
msg.value = "✓ In den Warenkorb gelegt";
|
||||
setTimeout(() => (msg.value = ""), 2000);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.id, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="text-gray-500">{{ i18n.t("common.loading") }}</div>
|
||||
<div v-else-if="product" class="grid md:grid-cols-2 gap-6">
|
||||
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img :src="product.image_url" :alt="i18n.pickI18n(product.name)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500">{{ product.sku }}</div>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ i18n.pickI18n(product.name) }}</h1>
|
||||
<div class="text-2xl text-brand-600 font-semibold mb-4">
|
||||
{{ product.price.toFixed(2) }} {{ product.currency }}
|
||||
</div>
|
||||
<p class="mb-4 text-gray-700">{{ i18n.pickI18n(product.description) }}</p>
|
||||
<div class="mb-4 text-sm" :class="product.stock > 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ product.stock > 0 ? `${i18n.t("shop.in_stock")} (${product.stock})` : i18n.t("shop.out_of_stock") }}
|
||||
</div>
|
||||
<button @click="addToCart" class="btn-primary" :disabled="!product.stock">
|
||||
{{ i18n.t("shop.add_to_cart") }}
|
||||
</button>
|
||||
<div v-if="msg" class="text-green-600 mt-2">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
frontend/shop/src/pages/Register.vue
Normal file
43
frontend/shop/src/pages/Register.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import { ref } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const email = ref("");
|
||||
const name = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.register(email.value, password.value, name.value);
|
||||
router.push("/account");
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-sm mx-auto card">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ i18n.t("shop.register") }}</h1>
|
||||
<form @submit.prevent="submit" class="space-y-3">
|
||||
<input v-model="name" :placeholder="i18n.t('common.name')" class="input" />
|
||||
<input v-model="email" type="email" :placeholder="i18n.t('common.email')" class="input" />
|
||||
<input v-model="password" type="password" :placeholder="i18n.t('common.password')" class="input" />
|
||||
<button class="btn-primary w-full" :disabled="auth.loading">
|
||||
{{ auth.loading ? "..." : i18n.t("shop.register") }}
|
||||
</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
<p class="text-sm text-gray-500 mt-4">
|
||||
Schon ein Konto?
|
||||
<RouterLink to="/login" class="text-brand-500">Anmelden</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
32
frontend/shop/src/router.ts
Normal file
32
frontend/shop/src/router.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Account from "./pages/Account.vue";
|
||||
import AISearch from "./pages/AISearch.vue";
|
||||
import Cart from "./pages/Cart.vue";
|
||||
import Category from "./pages/Category.vue";
|
||||
import Checkout from "./pages/Checkout.vue";
|
||||
import Home from "./pages/Home.vue";
|
||||
import Login from "./pages/Login.vue";
|
||||
import OrderDetail from "./pages/OrderDetail.vue";
|
||||
import Orders from "./pages/Orders.vue";
|
||||
import Product from "./pages/Product.vue";
|
||||
import Register from "./pages/Register.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: Home },
|
||||
{ path: "/ai", component: AISearch },
|
||||
{ path: "/category/:slug", component: Category, props: true },
|
||||
{ path: "/product/:id", component: Product, props: true },
|
||||
{ path: "/cart", component: Cart },
|
||||
{ path: "/checkout", component: Checkout },
|
||||
{ path: "/account", component: Account },
|
||||
{ path: "/account/orders", component: Orders },
|
||||
{ path: "/account/orders/:id", component: OrderDetail, props: true },
|
||||
{ path: "/login", component: Login },
|
||||
{ path: "/register", component: Register },
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
48
frontend/shop/src/stores/auth.ts
Normal file
48
frontend/shop/src/stores/auth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { clearTokens, saveTokens } from "@shop/shared/api";
|
||||
import type { User } from "@shop/shared/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "../api";
|
||||
|
||||
export const useAuth = defineStore("auth", {
|
||||
state: () => ({
|
||||
user: null as User | null,
|
||||
loading: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (s) => !!s.user,
|
||||
},
|
||||
actions: {
|
||||
async fetchMe() {
|
||||
try {
|
||||
const r = await api.get("/api/auth/me");
|
||||
this.user = r.data;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await api.post("/api/auth/login", { email, password });
|
||||
saveTokens(r.data.access_token, r.data.refresh_token);
|
||||
await this.fetchMe();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async register(email: string, password: string, name: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await api.post("/api/auth/register", { email, password, name });
|
||||
saveTokens(r.data.access_token, r.data.refresh_token);
|
||||
await this.fetchMe();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
clearTokens();
|
||||
this.user = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
42
frontend/shop/src/stores/cart.ts
Normal file
42
frontend/shop/src/stores/cart.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Cart } from "@shop/shared/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "../api";
|
||||
|
||||
export const useCart = defineStore("cart", {
|
||||
state: () => ({
|
||||
cart: { items: [], subtotal: 0 } as Cart,
|
||||
loading: false,
|
||||
}),
|
||||
getters: {
|
||||
count: (s) => s.cart.items.reduce((n, i) => n + i.qty, 0),
|
||||
},
|
||||
actions: {
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await api.get<Cart>("/api/cart");
|
||||
this.cart = r.data;
|
||||
} catch {
|
||||
this.cart = { items: [], subtotal: 0 };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async add(product_id: number, qty = 1) {
|
||||
const r = await api.post<Cart>("/api/cart/items", { product_id, qty });
|
||||
this.cart = r.data;
|
||||
},
|
||||
async update(product_id: number, qty: number) {
|
||||
const r = await api.put<Cart>(`/api/cart/items/${product_id}`, { qty });
|
||||
this.cart = r.data;
|
||||
},
|
||||
async remove(product_id: number) {
|
||||
const r = await api.delete<Cart>(`/api/cart/items/${product_id}`);
|
||||
this.cart = r.data;
|
||||
},
|
||||
async clear() {
|
||||
const r = await api.delete<Cart>("/api/cart");
|
||||
this.cart = r.data;
|
||||
},
|
||||
},
|
||||
});
|
||||
38
frontend/shop/src/stores/shop.ts
Normal file
38
frontend/shop/src/stores/shop.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Category, Product } from "@shop/shared/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "../api";
|
||||
|
||||
export const useShop = defineStore("shop", {
|
||||
state: () => ({
|
||||
products: [] as Product[],
|
||||
categories: [] as Category[],
|
||||
shopName: "Shop",
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
async loadProducts() {
|
||||
const r = await api.get<Product[]>("/api/catalog/products");
|
||||
this.products = r.data;
|
||||
},
|
||||
async loadCategories() {
|
||||
const r = await api.get<Category[]>("/api/catalog/categories");
|
||||
this.categories = r.data;
|
||||
},
|
||||
async loadShopName() {
|
||||
try {
|
||||
const r = await api.get("/api/core/settings/core.shop_name");
|
||||
this.shopName = r.data.value || "Shop";
|
||||
} catch {
|
||||
this.shopName = "Shop";
|
||||
}
|
||||
},
|
||||
async loadAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await Promise.all([this.loadProducts(), this.loadCategories(), this.loadShopName()]);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
24
frontend/shop/src/style.css
Normal file
24
frontend/shop/src/style.css
Normal file
@@ -0,0 +1,24 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn border border-gray-300 bg-white text-gray-700 hover:bg-gray-50;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-gray-300 px-3 py-2 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg bg-white p-4 shadow-sm;
|
||||
}
|
||||
17
frontend/shop/tailwind.config.js
Normal file
17
frontend/shop/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#f2f9f3",
|
||||
500: "#2e7d32",
|
||||
600: "#277528",
|
||||
700: "#1e5a22",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
22
frontend/shop/tsconfig.json
Normal file
22
frontend/shop/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@shop/shared": ["../shared/src/index.ts"],
|
||||
"@shop/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.vue"]
|
||||
}
|
||||
15
frontend/shop/vite.config.ts
Normal file
15
frontend/shop/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/uploads": "http://localhost:8000",
|
||||
"/health": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user