feat: add partner promotion feature for Zhipu GLM
- Add isPartner and partnerPromotionKey fields to Provider and ProviderPreset types - Display gold star badge on partner presets in selector - Show promotional message in API Key section for partners - Configure Zhipu GLM as official partner with 10% discount promotion - Support both Claude and Codex provider presets - Add i18n support for partner promotion messages (zh/en)
This commit is contained in:
@@ -19,6 +19,8 @@ interface ClaudeFormFieldsProps {
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
shouldShowApiKeyLink: boolean;
|
shouldShowApiKeyLink: boolean;
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
|
|
||||||
// Template Values
|
// Template Values
|
||||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||||
@@ -61,6 +63,8 @@ export function ClaudeFormFields({
|
|||||||
category,
|
category,
|
||||||
shouldShowApiKeyLink,
|
shouldShowApiKeyLink,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
templateValueEntries,
|
templateValueEntries,
|
||||||
templateValues,
|
templateValues,
|
||||||
templatePresetName,
|
templatePresetName,
|
||||||
@@ -91,6 +95,8 @@ export function ClaudeFormFields({
|
|||||||
category={category}
|
category={category}
|
||||||
shouldShowLink={shouldShowApiKeyLink}
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
websiteUrl={websiteUrl}
|
websiteUrl={websiteUrl}
|
||||||
|
isPartner={isPartner}
|
||||||
|
partnerPromotionKey={partnerPromotionKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface CodexFormFieldsProps {
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
shouldShowApiKeyLink: boolean;
|
shouldShowApiKeyLink: boolean;
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
|
|
||||||
// Base URL
|
// Base URL
|
||||||
shouldShowSpeedTest: boolean;
|
shouldShowSpeedTest: boolean;
|
||||||
@@ -35,6 +37,8 @@ export function CodexFormFields({
|
|||||||
category,
|
category,
|
||||||
shouldShowApiKeyLink,
|
shouldShowApiKeyLink,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
shouldShowSpeedTest,
|
shouldShowSpeedTest,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
onBaseUrlChange,
|
onBaseUrlChange,
|
||||||
@@ -56,6 +60,8 @@ export function CodexFormFields({
|
|||||||
category={category}
|
category={category}
|
||||||
shouldShowLink={shouldShowApiKeyLink}
|
shouldShowLink={shouldShowApiKeyLink}
|
||||||
websiteUrl={websiteUrl}
|
websiteUrl={websiteUrl}
|
||||||
|
isPartner={isPartner}
|
||||||
|
partnerPromotionKey={partnerPromotionKey}
|
||||||
placeholder={{
|
placeholder={{
|
||||||
official: t("providerForm.codexOfficialNoApiKey", {
|
official: t("providerForm.codexOfficialNoApiKey", {
|
||||||
defaultValue: "官方供应商无需 API Key",
|
defaultValue: "官方供应商无需 API Key",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function ProviderForm({
|
|||||||
const [activePreset, setActivePreset] = useState<{
|
const [activePreset, setActivePreset] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
|
isPartner?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -326,6 +327,10 @@ export function ProviderForm({
|
|||||||
if (activePreset.category) {
|
if (activePreset.category) {
|
||||||
payload.presetCategory = activePreset.category;
|
payload.presetCategory = activePreset.category;
|
||||||
}
|
}
|
||||||
|
// 继承合作伙伴标识
|
||||||
|
if (activePreset.isPartner) {
|
||||||
|
payload.isPartner = activePreset.isPartner;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints
|
// 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints
|
||||||
@@ -399,6 +404,8 @@ export function ProviderForm({
|
|||||||
const {
|
const {
|
||||||
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
||||||
websiteUrl: claudeWebsiteUrl,
|
websiteUrl: claudeWebsiteUrl,
|
||||||
|
isPartner: isClaudePartner,
|
||||||
|
partnerPromotionKey: claudePartnerPromotionKey,
|
||||||
} = useApiKeyLink({
|
} = useApiKeyLink({
|
||||||
appId: "claude",
|
appId: "claude",
|
||||||
category,
|
category,
|
||||||
@@ -411,6 +418,8 @@ export function ProviderForm({
|
|||||||
const {
|
const {
|
||||||
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
|
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
|
||||||
websiteUrl: codexWebsiteUrl,
|
websiteUrl: codexWebsiteUrl,
|
||||||
|
isPartner: isCodexPartner,
|
||||||
|
partnerPromotionKey: codexPartnerPromotionKey,
|
||||||
} = useApiKeyLink({
|
} = useApiKeyLink({
|
||||||
appId: "codex",
|
appId: "codex",
|
||||||
category,
|
category,
|
||||||
@@ -450,6 +459,7 @@ export function ProviderForm({
|
|||||||
setActivePreset({
|
setActivePreset({
|
||||||
id: value,
|
id: value,
|
||||||
category: entry.preset.category,
|
category: entry.preset.category,
|
||||||
|
isPartner: entry.preset.isPartner,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (appId === "codex") {
|
if (appId === "codex") {
|
||||||
@@ -523,6 +533,8 @@ export function ProviderForm({
|
|||||||
category={category}
|
category={category}
|
||||||
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
|
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
|
||||||
websiteUrl={claudeWebsiteUrl}
|
websiteUrl={claudeWebsiteUrl}
|
||||||
|
isPartner={isClaudePartner}
|
||||||
|
partnerPromotionKey={claudePartnerPromotionKey}
|
||||||
templateValueEntries={templateValueEntries}
|
templateValueEntries={templateValueEntries}
|
||||||
templateValues={templateValues}
|
templateValues={templateValues}
|
||||||
templatePresetName={templatePreset?.name || ""}
|
templatePresetName={templatePreset?.name || ""}
|
||||||
@@ -552,6 +564,8 @@ export function ProviderForm({
|
|||||||
category={category}
|
category={category}
|
||||||
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
|
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
|
||||||
websiteUrl={codexWebsiteUrl}
|
websiteUrl={codexWebsiteUrl}
|
||||||
|
isPartner={isCodexPartner}
|
||||||
|
partnerPromotionKey={codexPartnerPromotionKey}
|
||||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||||
codexBaseUrl={codexBaseUrl}
|
codexBaseUrl={codexBaseUrl}
|
||||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||||
@@ -612,5 +626,6 @@ export function ProviderForm({
|
|||||||
export type ProviderFormValues = ProviderFormData & {
|
export type ProviderFormValues = ProviderFormData & {
|
||||||
presetId?: string;
|
presetId?: string;
|
||||||
presetCategory?: ProviderCategory;
|
presetCategory?: ProviderCategory;
|
||||||
|
isPartner?: boolean;
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
import { ClaudeIcon, CodexIcon } from "@/components/BrandIcons";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap, Star } from "lucide-react";
|
||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
@@ -157,12 +157,13 @@ export function ProviderPresetSelector({
|
|||||||
if (!entries || entries.length === 0) return null;
|
if (!entries || entries.length === 0) return null;
|
||||||
return entries.map((entry) => {
|
return entries.map((entry) => {
|
||||||
const isSelected = selectedPresetId === entry.id;
|
const isSelected = selectedPresetId === entry.id;
|
||||||
|
const isPartner = entry.preset.isPartner;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPresetChange(entry.id)}
|
onClick={() => onPresetChange(entry.id)}
|
||||||
className={getPresetButtonClass(isSelected, entry.preset)}
|
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||||
title={
|
title={
|
||||||
presetCategoryLabels[category] ??
|
presetCategoryLabels[category] ??
|
||||||
@@ -173,6 +174,11 @@ export function ProviderPresetSelector({
|
|||||||
>
|
>
|
||||||
{renderPresetIcon(entry.preset)}
|
{renderPresetIcon(entry.preset)}
|
||||||
{entry.preset.name}
|
{entry.preset.name}
|
||||||
|
{isPartner && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||||
|
<Star className="h-2.5 w-2.5 fill-current" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,20 +37,34 @@ export function useApiKeyLink({
|
|||||||
);
|
);
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
|
// 获取当前预设条目
|
||||||
|
const currentPresetEntry = useMemo(() => {
|
||||||
|
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||||
|
return presetEntries.find((item) => item.id === selectedPresetId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [selectedPresetId, presetEntries]);
|
||||||
|
|
||||||
// 获取当前供应商的网址(用于 API Key 链接)
|
// 获取当前供应商的网址(用于 API Key 链接)
|
||||||
const getWebsiteUrl = useMemo(() => {
|
const getWebsiteUrl = useMemo(() => {
|
||||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
if (currentPresetEntry) {
|
||||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
const preset = currentPresetEntry.preset;
|
||||||
if (entry) {
|
// 第三方供应商优先使用 apiKeyUrl
|
||||||
const preset = entry.preset;
|
return preset.category === "third_party"
|
||||||
// 第三方供应商优先使用 apiKeyUrl
|
? preset.apiKeyUrl || preset.websiteUrl || ""
|
||||||
return preset.category === "third_party"
|
: preset.websiteUrl || "";
|
||||||
? preset.apiKeyUrl || preset.websiteUrl || ""
|
|
||||||
: preset.websiteUrl || "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return formWebsiteUrl || "";
|
return formWebsiteUrl || "";
|
||||||
}, [selectedPresetId, presetEntries, formWebsiteUrl]);
|
}, [currentPresetEntry, formWebsiteUrl]);
|
||||||
|
|
||||||
|
// 提取合作伙伴信息
|
||||||
|
const isPartner = useMemo(() => {
|
||||||
|
return currentPresetEntry?.preset.isPartner ?? false;
|
||||||
|
}, [currentPresetEntry]);
|
||||||
|
|
||||||
|
const partnerPromotionKey = useMemo(() => {
|
||||||
|
return currentPresetEntry?.preset.partnerPromotionKey;
|
||||||
|
}, [currentPresetEntry]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shouldShowApiKeyLink:
|
shouldShowApiKeyLink:
|
||||||
@@ -60,5 +74,7 @@ export function useApiKeyLink({
|
|||||||
? shouldShowApiKeyLink
|
? shouldShowApiKeyLink
|
||||||
: false,
|
: false,
|
||||||
websiteUrl: getWebsiteUrl,
|
websiteUrl: getWebsiteUrl,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface ApiKeySectionProps {
|
|||||||
thirdParty: string;
|
thirdParty: string;
|
||||||
};
|
};
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isPartner?: boolean;
|
||||||
|
partnerPromotionKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKeySection({
|
export function ApiKeySection({
|
||||||
@@ -27,6 +29,8 @@ export function ApiKeySection({
|
|||||||
websiteUrl,
|
websiteUrl,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
isPartner,
|
||||||
|
partnerPromotionKey,
|
||||||
}: ApiKeySectionProps) {
|
}: ApiKeySectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ export function ApiKeySection({
|
|||||||
/>
|
/>
|
||||||
{/* API Key 获取链接 */}
|
{/* API Key 获取链接 */}
|
||||||
{shouldShowLink && websiteUrl && (
|
{shouldShowLink && websiteUrl && (
|
||||||
<div className="-mt-1 pl-1">
|
<div className="space-y-2 -mt-1 pl-1">
|
||||||
<a
|
<a
|
||||||
href={websiteUrl}
|
href={websiteUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -68,6 +72,18 @@ export function ApiKeySection({
|
|||||||
defaultValue: "获取 API Key",
|
defaultValue: "获取 API Key",
|
||||||
})}
|
})}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* 合作伙伴促销信息 */}
|
||||||
|
{isPartner && partnerPromotionKey && (
|
||||||
|
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-2.5 border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-xs leading-relaxed text-blue-700 dark:text-blue-300">
|
||||||
|
💡{" "}
|
||||||
|
{t(`providerForm.partnerPromotion.${partnerPromotionKey}`, {
|
||||||
|
defaultValue: "",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface ProviderPreset {
|
|||||||
apiKeyUrl?: string;
|
apiKeyUrl?: string;
|
||||||
settingsConfig: object;
|
settingsConfig: object;
|
||||||
isOfficial?: boolean; // 标识是否为官方预设
|
isOfficial?: boolean; // 标识是否为官方预设
|
||||||
|
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||||
|
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||||
category?: ProviderCategory; // 新增:分类
|
category?: ProviderCategory; // 新增:分类
|
||||||
// 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN)
|
// 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN)
|
||||||
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||||
@@ -73,6 +75,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "Zhipu GLM",
|
name: "Zhipu GLM",
|
||||||
websiteUrl: "https://open.bigmodel.cn",
|
websiteUrl: "https://open.bigmodel.cn",
|
||||||
|
apiKeyUrl: "https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||||
@@ -84,6 +87,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
|
isPartner: true, // 商业合作伙伴
|
||||||
|
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Qwen Coder",
|
name: "Qwen Coder",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface CodexProviderPreset {
|
|||||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||||
isOfficial?: boolean; // 标识是否为官方预设
|
isOfficial?: boolean; // 标识是否为官方预设
|
||||||
|
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||||
|
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||||
category?: ProviderCategory; // 新增:分类
|
category?: ProviderCategory; // 新增:分类
|
||||||
isCustomTemplate?: boolean; // 标识是否为自定义模板
|
isCustomTemplate?: boolean; // 标识是否为自定义模板
|
||||||
// 新增:请求地址候选列表(用于地址管理/测速)
|
// 新增:请求地址候选列表(用于地址管理/测速)
|
||||||
|
|||||||
@@ -238,6 +238,9 @@
|
|||||||
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
||||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||||
"getApiKey": "Get API Key",
|
"getApiKey": "Get API Key",
|
||||||
|
"partnerPromotion": {
|
||||||
|
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount"
|
||||||
|
},
|
||||||
"parameterConfig": "Parameter Config - {{name}} *",
|
"parameterConfig": "Parameter Config - {{name}} *",
|
||||||
"mainModel": "Main Model (optional)",
|
"mainModel": "Main Model (optional)",
|
||||||
"mainModelPlaceholder": "e.g., GLM-4.6",
|
"mainModelPlaceholder": "e.g., GLM-4.6",
|
||||||
|
|||||||
@@ -238,6 +238,9 @@
|
|||||||
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
||||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||||
"getApiKey": "获取 API Key",
|
"getApiKey": "获取 API Key",
|
||||||
|
"partnerPromotion": {
|
||||||
|
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠"
|
||||||
|
},
|
||||||
"parameterConfig": "参数配置 - {{name}} *",
|
"parameterConfig": "参数配置 - {{name}} *",
|
||||||
"mainModel": "主模型 (可选)",
|
"mainModel": "主模型 (可选)",
|
||||||
"mainModelPlaceholder": "例如: GLM-4.6",
|
"mainModelPlaceholder": "例如: GLM-4.6",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Provider {
|
|||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
createdAt?: number; // 添加时间戳(毫秒)
|
createdAt?: number; // 添加时间戳(毫秒)
|
||||||
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
||||||
|
// 新增:是否为商业合作伙伴
|
||||||
|
isPartner?: boolean;
|
||||||
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
||||||
meta?: ProviderMeta;
|
meta?: ProviderMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user