Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and invoices flip together, so one purchase runs end to end against the backend. - cart: CartItemView carries the line's shop and the SKU's stock, so the cart keeps grouping per shop and the quantity stepper caps at real stock instead of a hard-coded 999 - contract: Shipment.items is optional and Invoice.invoice_no nullable, both matching what the API actually returns. Invoice was declared twice in types.ts and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect at all - an anonymous add-to-cart redirects to /login?redirect=..., and sign-in honours only same-origin paths - the fixed-data adapter learns the new cart fields, and its persisted state key moves to v2 because a cart saved by an older build is no longer valid - order surfaces drop their storeById lookups and keep the generic store label until the public store read arrives Verified end to end: two-shop cart grouping with live shop names, stock caps read from the API, checkout, payment, shipment, delivery confirmation and an issued invoice. Rollback re-verified with every domain on fixed data and the backend stopped. Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at the mock content that remains. OpenSpec change: openspec/changes/replace-mock-api-wave-3
184 lines
4.5 KiB
Vue
184 lines
4.5 KiB
Vue
<script setup lang="ts">
|
|
import { ApiError } from "@vmall/shared";
|
|
|
|
const { t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const session = useSessionStore();
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
const email = ref("");
|
|
const password = ref("");
|
|
const errorKey = ref("");
|
|
const submitting = ref(false);
|
|
|
|
/**
|
|
* Where to go after signing in. Only a same-origin absolute path is honoured:
|
|
* "//host" and "https://host" would otherwise turn this into an open redirect.
|
|
*/
|
|
const redirectTarget = computed((): string => {
|
|
const raw = route.query.redirect;
|
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : "/";
|
|
});
|
|
|
|
function validate(): boolean {
|
|
if (!email.value || !password.value) {
|
|
errorKey.value = "auth.validationRequired";
|
|
return false;
|
|
}
|
|
if (!/^\S+@\S+\.\S+$/.test(email.value)) {
|
|
errorKey.value = "auth.validationEmail";
|
|
return false;
|
|
}
|
|
// No length rule here: the auth API owns the password policy, and a wrong
|
|
// password should be reported as rejected credentials, not as bad input.
|
|
return true;
|
|
}
|
|
|
|
async function submit(): Promise<void> {
|
|
errorKey.value = "";
|
|
if (!validate()) return;
|
|
submitting.value = true;
|
|
try {
|
|
const auth = await $api.login(email.value, password.value);
|
|
session.setAuth(auth);
|
|
await router.push(redirectTarget.value);
|
|
} catch (error) {
|
|
errorKey.value = error instanceof ApiError && error.status === 401
|
|
? "auth.invalidCredentials"
|
|
: "auth.requestFailed";
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="auth-page">
|
|
<div class="auth-inner">
|
|
<div class="auth-panel">
|
|
<div class="auth-tabs">
|
|
<h1>{{ t("auth.loginTab") }}</h1>
|
|
<NuxtLink to="/register">{{ t("auth.registerTab") }}</NuxtLink>
|
|
</div>
|
|
<form @submit.prevent="submit">
|
|
<label class="field">
|
|
<span>{{ t("common.email") }}</span>
|
|
<input v-model.trim="email" class="minput" type="email" autocomplete="email" :placeholder="t('auth.emailPlaceholder')" />
|
|
</label>
|
|
<label class="field">
|
|
<span>{{ t("common.password") }}</span>
|
|
<input v-model="password" class="minput" type="password" autocomplete="current-password" :placeholder="t('auth.passwordPlaceholder')" />
|
|
</label>
|
|
<p v-if="errorKey" class="error">{{ t(errorKey) }}</p>
|
|
<button class="mbtn red block submit" type="submit" :disabled="submitting">
|
|
{{ t("auth.loginAction") }}
|
|
</button>
|
|
</form>
|
|
<div class="auth-links">
|
|
<span>{{ t("auth.noAccount") }}</span>
|
|
<NuxtLink to="/register">{{ t("auth.registerNow") }}</NuxtLink>
|
|
<NuxtLink to="/forgot-password">{{ t("auth.forgotPassword") }}</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.auth-page {
|
|
min-height: 620px;
|
|
background: #f6f6f6 url("/mock/login-bg.svg") center top / cover no-repeat;
|
|
padding: 50px 0;
|
|
box-sizing: border-box;
|
|
}
|
|
.auth-inner {
|
|
width: 1200px;
|
|
min-height: 450px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: flex-start;
|
|
}
|
|
.auth-panel {
|
|
width: 400px;
|
|
min-height: 450px;
|
|
box-sizing: border-box;
|
|
padding: 40px;
|
|
background: #fff;
|
|
box-shadow: 0 4px 22px rgba(0, 0, 0, 0.08);
|
|
}
|
|
.auth-tabs {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 18px;
|
|
margin-bottom: 28px;
|
|
border-bottom: 1px solid var(--mall-line);
|
|
padding-bottom: 14px;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
color: var(--mall-ink);
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
}
|
|
.auth-tabs a {
|
|
color: var(--mall-faint);
|
|
font-size: 14px;
|
|
text-decoration: none;
|
|
}
|
|
.auth-tabs a:hover,
|
|
.auth-links a:hover {
|
|
color: var(--mall-red);
|
|
}
|
|
.field {
|
|
display: block;
|
|
margin-bottom: 18px;
|
|
}
|
|
.field span {
|
|
display: block;
|
|
margin-bottom: 7px;
|
|
color: var(--mall-muted);
|
|
font-size: 13px;
|
|
}
|
|
.submit {
|
|
margin-top: 4px;
|
|
}
|
|
.error {
|
|
margin: -4px 0 12px;
|
|
color: var(--mall-red);
|
|
font-size: 12px;
|
|
}
|
|
.auth-links {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 18px;
|
|
color: var(--mall-faint);
|
|
font-size: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.auth-links a {
|
|
color: var(--mall-red);
|
|
text-decoration: none;
|
|
}
|
|
@media (max-width: 1240px) {
|
|
.auth-inner {
|
|
width: calc(100% - 40px);
|
|
}
|
|
}
|
|
@media (max-width: 640px) {
|
|
.auth-page {
|
|
padding: 20px;
|
|
}
|
|
.auth-inner {
|
|
width: 100%;
|
|
}
|
|
.auth-panel {
|
|
width: 100%;
|
|
padding: 30px 24px;
|
|
}
|
|
}
|
|
</style>
|