diff --git a/src/giffgaff/js/giffgaff-app.js b/src/giffgaff/js/giffgaff-app.js
index 44e8013..6399459 100644
--- a/src/giffgaff/js/giffgaff-app.js
+++ b/src/giffgaff/js/giffgaff-app.js
@@ -20,6 +20,7 @@ import {
downloadQRCode
} from './modules/utils.js';
import { t, tl } from '../../js/modules/i18n.js';
+import HtmlSanitizer from '../../js/modules/html-sanitizer.js';
import { isMobileBrowser, showMobileWarning } from '../../js/modules/browser-utils.js';
import captchaManager from '../../js/modules/captcha-manager.js';
import Logger from '../../js/modules/logger.js';
@@ -357,6 +358,9 @@ class GiffgaffApp {
// SMS内联流程
this.bindSmsInlineFlow();
+ // 直取eSIM流程
+ this.bindDirectFetchActions();
+
// 手动输入eSIM信息
this.bindManualEsimInput();
@@ -413,6 +417,116 @@ class GiffgaffApp {
}
}
+ /**
+ * 绑定直取eSIM卡片与按钮事件
+ */
+ bindDirectFetchActions() {
+ const directCard = document.getElementById('directFetchCard');
+ if (directCard && !directCard.__bound) {
+ directCard.__bound = true;
+ const handler = () => this.handleDirectFetchExpand();
+ directCard.addEventListener('click', handler);
+ directCard.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handler();
+ }
+ });
+ }
+
+ const pullBtn = document.getElementById('directFetchPullBtn');
+ if (pullBtn && !pullBtn.__bound) {
+ pullBtn.__bound = true;
+ pullBtn.addEventListener('click', () => this.handleDirectFetchPull());
+ }
+ }
+
+ /**
+ * 处理直取eSIM卡片展开
+ */
+ handleDirectFetchExpand() {
+ const manualBlock = document.getElementById('manualBlock');
+ const smsSection = document.getElementById('smsInlineSection');
+ const directSection = document.getElementById('directFetchSection');
+ if (manualBlock) manualBlock.style.display = 'none';
+ if (smsSection) smsSection.style.display = 'none';
+ if (directSection) {
+ directSection.style.display = 'block';
+ directSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ const pullBtn = document.getElementById('directFetchPullBtn');
+ if (pullBtn) pullBtn.focus();
+ }
+ }
+
+ /**
+ * 处理直取eSIM拉取操作
+ */
+ async handleDirectFetchPull(preselectedSsn = null) {
+ const pullBtn = document.getElementById('directFetchPullBtn');
+ const statusEl = document.getElementById('directFetchStatus');
+ if (!pullBtn || !statusEl) return;
+
+ try {
+ pullBtn.disabled = true;
+ pullBtn.innerHTML = `
${tl('拉取中...')}`;
+ uiController.showStatus(statusEl, t('giffgaff.directFetch.status.fetching'), 'success');
+
+ await esimService.directFetchFlow(preselectedSsn);
+ uiController.showStatus(statusEl, t('giffgaff.directFetch.status.success'), 'success');
+
+ setTimeout(() => {
+ uiController.showSection(5);
+ uiController.showESimResult();
+ }, 800);
+ } catch (error) {
+ if (error.code === 'MULTIPLE_ESIMS') {
+ this.renderSsnPicker(error.candidates);
+ uiController.showStatus(statusEl, t('giffgaff.directFetch.errors.multipleNeedPick'), 'info');
+ } else if (error.code === 'EMPTY_LIST') {
+ uiController.showStatus(statusEl, t('giffgaff.directFetch.errors.empty'), 'error');
+ } else {
+ uiController.showStatus(statusEl, t('giffgaff.directFetch.errors.generic', { message: error.message }), 'error');
+ }
+ } finally {
+ pullBtn.disabled = false;
+ pullBtn.innerHTML = `
${tl('拉取我的 eSIM')}`;
+ }
+ }
+
+ /**
+ * 渲染多eSIM选择器(radio列表 + 确认按钮)
+ */
+ renderSsnPicker(candidates) {
+ const container = document.getElementById('directFetchSsnPicker');
+ if (!container) return;
+
+ container.innerHTML = candidates.map((ssn, i) => {
+ const safeSsn = HtmlSanitizer.escapeHtml(ssn);
+ return `
+
+
+ ${safeSsn}
+
`;
+ }).join('') + `
+
+ ${tl('使用选定的 eSIM')}
+
+ `;
+ container.style.display = 'block';
+
+ const confirmBtn = document.getElementById('directFetchSsnConfirmBtn');
+ if (confirmBtn && !confirmBtn.__bound) {
+ confirmBtn.__bound = true;
+ confirmBtn.addEventListener('click', () => {
+ const selected = document.querySelector('input[name="directFetchSsn"]:checked')?.value;
+ if (!selected) return;
+ container.style.display = 'none';
+ this.handleDirectFetchPull(selected);
+ });
+ }
+ }
+
/**
* 绑定步骤导航
*/
diff --git a/src/giffgaff/js/modules/api-config.js b/src/giffgaff/js/modules/api-config.js
index de2bb92..63f8239 100644
--- a/src/giffgaff/js/modules/api-config.js
+++ b/src/giffgaff/js/modules/api-config.js
@@ -84,6 +84,13 @@ export const graphqlQueries = {
}
}`,
+ getESims: `query getESims($deliveryStatus: ESimDeliveryStatus!) {
+ eSims(deliveryStatus: $deliveryStatus) {
+ ssn
+ __typename
+ }
+ }`,
+
simSwapMfaChallenge: `mutation simSwapMfaChallenge {
simSwapMfaChallenge {
ref
diff --git a/src/giffgaff/js/modules/esim-service.js b/src/giffgaff/js/modules/esim-service.js
index 571d5ce..83be1a4 100644
--- a/src/giffgaff/js/modules/esim-service.js
+++ b/src/giffgaff/js/modules/esim-service.js
@@ -375,6 +375,82 @@ export class ESimService {
throw new Error(t('giffgaff.esim.errors.lpaTimeout'));
}
+ /**
+ * 直接拉取账户上已存在的 eSIM 列表(已支付但 App 未下发的场景)
+ */
+ async fetchExistingESims() {
+ const state = stateManager.getState();
+ if (!state.accessToken) {
+ throw new Error(t('giffgaff.directFetch.errors.missingToken'));
+ }
+
+ const response = await fetch(this.apiEndpoints.graphql, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ accessToken: state.accessToken,
+ cookie: stateManager.getCookie() || undefined,
+ query: graphqlQueries.getESims,
+ variables: { deliveryStatus: 'DOWNLOADABLE' },
+ operationName: 'getESims'
+ })
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(t('giffgaff.directFetch.errors.generic', {
+ message: `${response.status} - ${errorText}`
+ }));
+ }
+
+ const data = await response.json();
+ if (data.errors) {
+ const errorObj = data.errors[0];
+ throw new Error(errorObj?.message || errorObj?.error || JSON.stringify(errorObj));
+ }
+
+ return data.data.eSims || [];
+ }
+
+ /**
+ * 直取 eSIM 完整流程:拉取列表 → 选定 SSN → 获取 LPA
+ * @param {string|null} preselectedSsn - 已选定的 SSN(多选场景)
+ */
+ async directFetchFlow(preselectedSsn = null) {
+ stateManager.set('directFetchMode', true);
+ const list = await this.fetchExistingESims();
+
+ if (list.length === 0) {
+ const err = new Error(t('giffgaff.directFetch.errors.empty'));
+ err.code = 'EMPTY_LIST';
+ throw err;
+ }
+
+ if (list.length > 1 && !preselectedSsn) {
+ const err = new Error(t('giffgaff.directFetch.errors.multipleNeedPick'));
+ err.code = 'MULTIPLE_ESIMS';
+ err.candidates = list.map(e => e.ssn);
+ throw err;
+ }
+
+ if (preselectedSsn && !list.some(e => e.ssn === preselectedSsn)) {
+ const err = new Error(t('giffgaff.directFetch.errors.generic', { message: 'Invalid SSN selection' }));
+ err.code = 'INVALID_SSN';
+ throw err;
+ }
+
+ const ssn = preselectedSsn || list[0].ssn;
+ const tokenResult = await this.getESimDownloadToken(ssn);
+
+ stateManager.setState({
+ esimSSN: ssn,
+ esimDeliveryStatus: 'DOWNLOADABLE',
+ lpaString: tokenResult.lpaString
+ });
+
+ return { success: true, ssn, lpaString: tokenResult.lpaString };
+ }
+
/**
* 为SIM交换验证MFA验证码
*/
diff --git a/src/giffgaff/js/modules/state-manager.js b/src/giffgaff/js/modules/state-manager.js
index 5e1a485..1d69046 100644
--- a/src/giffgaff/js/modules/state-manager.js
+++ b/src/giffgaff/js/modules/state-manager.js
@@ -31,6 +31,7 @@ export class StateManager {
// 模式
isDeviceChange: true,
+ directFetchMode: false,
// 步骤控制
currentStep: 1
@@ -273,6 +274,7 @@ export class StateManager {
esimDeliveryStatus: "",
lpaString: "",
isDeviceChange: true,
+ directFetchMode: false,
currentStep: 1
};
diff --git a/src/giffgaff/js/modules/ui-controller.js b/src/giffgaff/js/modules/ui-controller.js
index 9312388..d57ab25 100644
--- a/src/giffgaff/js/modules/ui-controller.js
+++ b/src/giffgaff/js/modules/ui-controller.js
@@ -161,7 +161,10 @@ export class UIController {
// 模式显示
if (this.elements.statusMode) {
- if (state.isDeviceChange) {
+ if (state.directFetchMode) {
+ this.elements.statusMode.textContent = tl('已购用户获取eSIM(仅限新购)');
+ this.elements.statusMode.className = 'status-value connected';
+ } else if (state.isDeviceChange) {
this.elements.statusMode.textContent = tl('设备更换');
this.elements.statusMode.className = 'status-value connected';
} else {
diff --git a/src/giffgaff/styles/giffgaff-components.css b/src/giffgaff/styles/giffgaff-components.css
index 9eb1de6..4a0e6dc 100644
--- a/src/giffgaff/styles/giffgaff-components.css
+++ b/src/giffgaff/styles/giffgaff-components.css
@@ -268,6 +268,10 @@
border-left: 4px solid var(--warning);
}
+.status.info {
+ border-left: 4px solid var(--success);
+}
+
/* ===== 状态网格 ===== */
.status-grid {
display: grid;
diff --git a/src/js/modules/i18n-data.js b/src/js/modules/i18n-data.js
index d88ebe0..3ef3473 100644
--- a/src/js/modules/i18n-data.js
+++ b/src/js/modules/i18n-data.js
@@ -460,6 +460,17 @@ export const TRANSLATIONS = {
'giffgaff.mfa.errors.invalidRef': '未返回有效的验证码引用',
'giffgaff.mfa.log.swapSendFailed': '发送SIM交换验证码失败:',
+ 'giffgaff.directFetch.status.fetching': '正在拉取已有的 eSIM...',
+ 'giffgaff.directFetch.status.success': '成功获取 eSIM 信息,正在生成二维码...',
+ 'giffgaff.directFetch.errors.missingToken': '缺少访问令牌,请重新进行 OAuth 登录',
+ 'giffgaff.directFetch.errors.empty': '未在您的账户上发现可下载的 eSIM。如确认已在官方 App 完成 10 英镑付款,请联系 Giffgaff 客服核实订单状态。',
+ 'giffgaff.directFetch.errors.multipleNeedPick': '检测到多个可下载的 eSIM,请选择一个继续',
+ 'giffgaff.directFetch.errors.generic': '直取 eSIM 失败:{message}',
+ '已购用户获取eSIM(仅限新购)': '已购用户获取eSIM(仅限新购)',
+ '拉取中...': '拉取中...',
+ '拉取我的 eSIM': '拉取我的 eSIM',
+ '使用选定的 eSIM': '使用选定的 eSIM',
+
'simyo.meta.title': 'Simyo eSIM 工具',
'simyo.meta.description': 'Simyo eSIM 工具,支持设备更换、邮箱验证码、二维码生成与安装确认,帮助您快速管理荷兰 Simyo eSIM。',
'simyo.body.ariaLabel': 'Simyo eSIM 工具',
@@ -713,6 +724,17 @@ export const TRANSLATIONS = {
'giffgaff.mfa.errors.invalidRef': 'No valid verification reference returned.',
'giffgaff.mfa.log.swapSendFailed': 'Failed to send SIM swap verification code:',
+ 'giffgaff.directFetch.status.fetching': 'Fetching existing eSIM...',
+ 'giffgaff.directFetch.status.success': 'eSIM info retrieved successfully — generating QR code...',
+ 'giffgaff.directFetch.errors.missingToken': 'Missing access token. Please re-run OAuth login.',
+ 'giffgaff.directFetch.errors.empty': 'No downloadable eSIM found on your account. If you have completed the £10 payment in the official app, please contact Giffgaff support to verify your order.',
+ 'giffgaff.directFetch.errors.multipleNeedPick': 'Multiple downloadable eSIMs detected — please pick one to continue',
+ 'giffgaff.directFetch.errors.generic': 'Direct fetch failed: {message}',
+ '已购用户获取eSIM(仅限新购)': 'Paid user eSIM fetch (new-purchase only)',
+ '拉取中...': 'Fetching...',
+ '拉取我的 eSIM': 'Fetch my eSIM',
+ '使用选定的 eSIM': 'Use selected eSIM',
+
'simyo.meta.title': 'Simyo eSIM Tool',
'simyo.meta.description': 'Simyo eSIM toolkit with device change, email verification, QR generation, and installation confirmation for Dutch Simyo users.',
'simyo.body.ariaLabel': 'Simyo eSIM Tool',
diff --git a/tests/giffgaff/direct-fetch.test.js b/tests/giffgaff/direct-fetch.test.js
new file mode 100644
index 0000000..6bd4731
--- /dev/null
+++ b/tests/giffgaff/direct-fetch.test.js
@@ -0,0 +1,202 @@
+/**
+ * 直取 eSIM 功能单元测试
+ * 覆盖 fetchExistingESims / directFetchFlow 的正常与异常路径
+ */
+
+// 模拟 secure-storage 模块(必须在 import 之前)
+jest.mock('../../src/js/modules/secure-storage.js', () => ({
+ __esModule: true,
+ default: {
+ getItem: jest.fn(() => null),
+ setItem: jest.fn(),
+ removeItem: jest.fn()
+ }
+}));
+
+// 模拟 i18n 模块
+jest.mock('../../src/js/modules/i18n.js', () => ({
+ __esModule: true,
+ t: jest.fn((key, vars) => {
+ const map = {
+ 'giffgaff.directFetch.errors.missingToken': '缺少访问令牌',
+ 'giffgaff.directFetch.errors.empty': '未发现可下载的 eSIM',
+ 'giffgaff.directFetch.errors.multipleNeedPick': '检测到多个可下载的 eSIM',
+ 'giffgaff.directFetch.errors.generic': '直取 eSIM 失败:' + (vars?.message || ''),
+ };
+ return map[key] || key;
+ }),
+ tl: jest.fn((key) => key)
+}));
+
+// 模拟 utils 模块
+jest.mock('../../src/giffgaff/js/modules/utils.js', () => ({
+ __esModule: true,
+ isNetlifyEnvironment: jest.fn(() => false)
+}));
+
+import { stateManager } from '../../src/giffgaff/js/modules/state-manager.js';
+import { esimService } from '../../src/giffgaff/js/modules/esim-service.js';
+
+describe('ESimService - directFetchFlow', () => {
+ beforeEach(() => {
+ // 重置状态
+ stateManager.setState({
+ accessToken: '',
+ emailSignature: '',
+ memberId: '',
+ esimSSN: '',
+ esimActivationCode: '',
+ esimDeliveryStatus: '',
+ lpaString: '',
+ directFetchMode: false
+ });
+
+ // 重置 fetch mock
+ global.fetch = jest.fn();
+ });
+
+ describe('fetchExistingESims', () => {
+ test('成功返回 eSIM 列表', async () => {
+ stateManager.setState({ accessToken: 'valid-token' });
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [{ ssn: 'SSN-001', __typename: 'ESim' }] }
+ })
+ });
+
+ const result = await esimService.fetchExistingESims();
+ expect(result).toEqual([{ ssn: 'SSN-001', __typename: 'ESim' }]);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ test('缺 accessToken 时抛出异常', async () => {
+ stateManager.setState({ accessToken: '' });
+ await expect(esimService.fetchExistingESims()).rejects.toThrow(/访问令牌/);
+ });
+
+ test('HTTP 错误时抛出异常', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ text: async () => 'Internal Server Error'
+ });
+
+ await expect(esimService.fetchExistingESims()).rejects.toThrow(/500/);
+ });
+
+ test('GraphQL errors 时抛出对应 message', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ errors: [{ message: 'Unauthorized' }]
+ })
+ });
+
+ await expect(esimService.fetchExistingESims()).rejects.toThrow('Unauthorized');
+ });
+
+ test('返回空列表时返回空数组', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [] }
+ })
+ });
+
+ const result = await esimService.fetchExistingESims();
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('directFetchFlow', () => {
+ test('单 eSIM 时自动完成并写入状态', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [{ ssn: 'SSN-A', __typename: 'ESim' }] }
+ })
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSimDownloadToken: { lpaString: 'LPA:1$test', host: 'h', matchingId: 'm' } }
+ })
+ });
+
+ const result = await esimService.directFetchFlow();
+
+ expect(result.success).toBe(true);
+ expect(result.ssn).toBe('SSN-A');
+ expect(result.lpaString).toBe('LPA:1$test');
+ expect(stateManager.get('esimSSN')).toBe('SSN-A');
+ expect(stateManager.get('lpaString')).toBe('LPA:1$test');
+ expect(stateManager.get('directFetchMode')).toBe(true);
+ expect(stateManager.get('esimDeliveryStatus')).toBe('DOWNLOADABLE');
+ });
+
+ test('多 eSIM 未选定 preselectedSsn 时抛出 MULTIPLE_ESIMS', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [{ ssn: 'A' }, { ssn: 'B' }] }
+ })
+ });
+
+ try {
+ await esimService.directFetchFlow();
+ fail('应抛出 MULTIPLE_ESIMS 错误');
+ } catch (error) {
+ expect(error.code).toBe('MULTIPLE_ESIMS');
+ expect(error.candidates).toEqual(['A', 'B']);
+ }
+ });
+
+ test('多 eSIM 已选定 preselectedSsn 时直接使用该 ssn', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [{ ssn: 'A' }, { ssn: 'B' }] }
+ })
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSimDownloadToken: { lpaString: 'LPA:1$B-token', host: 'h', matchingId: 'm' } }
+ })
+ });
+
+ const result = await esimService.directFetchFlow('B');
+
+ expect(result.success).toBe(true);
+ expect(result.ssn).toBe('B');
+ expect(result.lpaString).toBe('LPA:1$B-token');
+ expect(stateManager.get('esimSSN')).toBe('B');
+ });
+
+ test('空列表时抛出 EMPTY_LIST', async () => {
+ stateManager.setState({ accessToken: 'tok' });
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { eSims: [] }
+ })
+ });
+
+ try {
+ await esimService.directFetchFlow();
+ fail('应抛出 EMPTY_LIST 错误');
+ } catch (error) {
+ expect(error.code).toBe('EMPTY_LIST');
+ }
+ });
+ });
+});