feat(member): account/profile polish + tier upgrade flow

- Timezone: curated USelectMenu dropdown (app/config/timezones.js), preserves unknown saved values
- Profile save now uses useToast() for success/error; remove inline save banner
- Nav onboarding dot nudged down 1px for optical alignment with lowercase text
- Onboarding: skip a suggestion with POST /api/onboarding/track {skip}; member.onboarding.skipped map; does not affect graduation
- CirclePicker takes :saved-value so 'Current' badge stays until save completes
- PrivacyToggle is binary (USwitch labeled Private); member schema enum reduced to ['members','private']; zod coerces legacy 'public'
- New /member/payment-setup page: HelcimPay $0 verify + update-contribution, wired from account.vue via requiresPaymentSetup redirect
- Helcim portal: NUXT_PUBLIC_HELCIM_PORTAL_URL env + account.vue 'Manage billing in Helcim' link
- Migration script: scripts/migrate-privacy-public-to-members.js
This commit is contained in:
Jennie Robinson Faber 2026-04-14 20:35:37 +01:00
parent 08fc3884da
commit 7292b11c0b
18 changed files with 604 additions and 122 deletions

View file

@ -1,66 +1,44 @@
<template>
<div class="priv segmented">
<span
v-for="opt in options"
:key="opt.value"
:class="{ on: modelValue === opt.value }"
@click="$emit('update:modelValue', opt.value)"
>{{ opt.label }}</span
>
</div>
<label class="priv">
<USwitch
:model-value="isPrivate"
aria-label="Private"
size="xs"
@update:model-value="onChange"
/>
<span class="priv-label">Private</span>
</label>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: "public" },
const props = defineProps({
modelValue: { type: String, default: "members" },
});
defineEmits(["update:modelValue"]);
const emit = defineEmits(["update:modelValue"]);
const options = [
{ label: "Public", value: "public" },
{ label: "Members", value: "members" },
{ label: "Private", value: "private" },
];
// Treat legacy "public" values as "members" (visible to signed-in members).
const isPrivate = computed(() => props.modelValue === "private");
const onChange = (val) => {
emit("update:modelValue", val ? "private" : "members");
};
</script>
<style scoped>
.priv {
display: inline-flex;
gap: 0;
font-size: 9px;
font-family: "Commit Mono", monospace;
letter-spacing: 0.02em;
}
.priv span {
padding: 2px 7px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
gap: 6px;
font-size: 10px;
font-family: "Commit Mono", monospace;
letter-spacing: 0.04em;
color: var(--text-faint);
cursor: pointer;
transition: all 0.12s;
user-select: none;
white-space: nowrap;
position: relative;
cursor: pointer;
}
.priv span + span {
margin-left: -1px;
}
.priv span:hover {
color: var(--text-dim);
}
.priv span.on {
background: var(--surface);
color: var(--text-bright);
border-color: var(--candle);
border-style: solid;
z-index: 1;
.priv-label {
line-height: 1;
}
</style>