wahnsinn vibe

This commit is contained in:
Marek Lenczewski
2026-04-16 19:42:06 +02:00
parent 9c5da44f64
commit e3e88cc58e
127 changed files with 9456 additions and 3 deletions

12
frontend/admin/index.html Normal file
View 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>

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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>

View File

@@ -0,0 +1,2 @@
import { createApi } from "@shop/shared/api";
export const api = createApi("");

View 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>

View 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>

View 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>

View 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");

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 },
],
});

View 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;
},
},
});

View 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;
}

View 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: [],
};

View 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"]
}

View 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
View File

@@ -0,0 +1,5 @@
{
"name": "shop-frontend-workspace",
"private": true,
"packageManager": "pnpm@10.33.0"
}

1832
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
packages:
- "shared"
- "shop"
- "admin"
onlyBuiltDependencies:
- esbuild

View 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"
}
}

View 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);
}

View 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",
};

View 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",
};

View 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 || "";
}

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * as i18n from "./i18n";

View 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
View 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>

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

67
frontend/shop/src/App.vue Normal file
View 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
View File

@@ -0,0 +1,3 @@
import { createApi } from "@shop/shared/api";
export const api = createApi("");

View 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>

View 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>

View 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
View 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");

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 };
},
});

View 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;
},
},
});

View 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;
},
},
});

View 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;
}
},
},
});

View 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;
}

View 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: [],
};

View 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"]
}

View 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",
},
},
});